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
| @@ -27,6 +27,12 @@ export default defineModel({ | ||
| 27 | 27 | // Public ingest key: the SDK sends it as `X-LogHQ-Key` so reports can only |
| 28 | 28 | // be written to a project that presents its key (revocable, not a secret). |
| 29 | 29 | ingest_key: { fillable: true, validation: { rule: schema.string().optional() }, factory: () => globalThis.crypto.randomUUID().replace(/-/g, '') }, |
| 30 | // Secret read key: a machine presents it as `Authorization: Bearer` to read | |
| 31 | // this project without a person's credentials (#14). Not fillable and with | |
| 32 | // no factory — it is issued on demand from project settings, and revoking | |
| 33 | // it is setting it back to null. See app/Support/read-key.ts. | |
| 34 | read_key: { fillable: false, validation: { rule: schema.string().optional() } }, | |
| 35 | read_key_last_used_at: { fillable: false, validation: { rule: schema.string().optional() } }, | |
| 30 | 36 | owner_id: { fillable: true, validation: { rule: schema.number().optional() } }, |
| 31 | 37 | is_active: { fillable: true, validation: { rule: schema.boolean().optional() }, factory: () => true }, |
| 32 | 38 | }, |
| @@ -0,0 +1,104 @@ | ||
| 1 | /** | |
| 2 | * A project's read key: the credential a machine presents to read that | |
| 3 | * project's logs. | |
| 4 | * | |
| 5 | * The ingest key next to it is deliberately public — it ships inside client | |
| 6 | * bundles, it only permits writes to one project, and `app/Errors/ingest.ts` | |
| 7 | * compares it with `===` because nothing is lost by leaking it. A read key is | |
| 8 | * the opposite on both counts: it returns data, and it is meant to sit in a | |
| 9 | * server's configuration and nowhere else. So it is longer, it is compared in | |
| 10 | * constant time, and it is never accepted on an ingest route. | |
| 11 | * | |
| 12 | * It authorises exactly one project. There is no account behind it, so it | |
| 13 | * outlives the person who created it and carries none of their scope — which | |
| 14 | * is the whole reason it exists (loghqorg/loghq#14): a Stacks dashboard | |
| 15 | * reading logs server-to-server should not be holding somebody's session. | |
| 16 | */ | |
| 17 | ||
| 18 | /** | |
| 19 | * `lhq_read_` then 256 bits of hex. | |
| 20 | * | |
| 21 | * The prefix is doing real work. It tells a read key apart from a user's | |
| 22 | * bearer token before either is verified, so a read key is never handed to | |
| 23 | * `Auth.getUserFromToken`; and it gives secret scanners something to match, so | |
| 24 | * a key pasted into a repository can be found and revoked. | |
| 25 | */ | |
| 26 | const PREFIX = 'lhq_read_' | |
| 27 | const BODY = /^[0-9a-f]{64}$/ | |
| 28 | ||
| 29 | export function newReadKey(): string { | |
| 30 | const bytes = globalThis.crypto.getRandomValues(new Uint8Array(32)) | |
| 31 | return PREFIX + Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('') | |
| 32 | } | |
| 33 | ||
| 34 | /** Whether a presented credential is shaped like a read key at all. */ | |
| 35 | export function looksLikeReadKey(value: unknown): value is string { | |
| 36 | return typeof value === 'string' && value.startsWith(PREFIX) && BODY.test(value.slice(PREFIX.length)) | |
| 37 | } | |
| 38 | ||
| 39 | /** | |
| 40 | * Equal, in time that does not depend on how much of the key is right. | |
| 41 | * | |
| 42 | * `===` on a string short-circuits at the first differing byte, which leaks | |
| 43 | * the length of a correct prefix to anyone who can measure the response. That | |
| 44 | * is a real attack on a secret an attacker can guess at repeatedly, and not | |
| 45 | * one on the public ingest key, which is why only this side does it. | |
| 46 | */ | |
| 47 | export function sameKey(presented: string, stored: string): boolean { | |
| 48 | if (presented.length !== stored.length) | |
| 49 | return false | |
| 50 | let diff = 0 | |
| 51 | for (let i = 0; i < presented.length; i++) | |
| 52 | diff |= presented.charCodeAt(i) ^ stored.charCodeAt(i) | |
| 53 | return diff === 0 | |
| 54 | } | |
| 55 | ||
| 56 | export interface ReadKeyProject { | |
| 57 | id: string | |
| 58 | read_key?: string | null | |
| 59 | is_active?: boolean | number | null | |
| 60 | } | |
| 61 | ||
| 62 | export type ReadAuthorization = | |
| 63 | | { ok: true } | |
| 64 | | { ok: false, status: 401 | 403 | 404, error: string } | |
| 65 | ||
| 66 | /** | |
| 67 | * Whether this key may read this project. | |
| 68 | * | |
| 69 | * A project nobody can name is a 404 rather than a 401: answering "that | |
| 70 | * project exists, your key is wrong" tells an unauthenticated caller which | |
| 71 | * project ids are real. | |
| 72 | */ | |
| 73 | export function authorizeRead(project: ReadKeyProject | null | undefined, presented: unknown): ReadAuthorization { | |
| 74 | if (!project) | |
| 75 | return { ok: false, status: 404, error: 'unknown project' } | |
| 76 | if (project.is_active === false || project.is_active === 0) | |
| 77 | return { ok: false, status: 403, error: 'project inactive' } | |
| 78 | // No key issued is not the same as "any key will do": a project without one | |
| 79 | // must refuse every presented key, including an empty one. | |
| 80 | if (!looksLikeReadKey(project.read_key)) | |
| 81 | return { ok: false, status: 403, error: 'project has no read key' } | |
| 82 | if (!looksLikeReadKey(presented) || !sameKey(presented, project.read_key)) | |
| 83 | return { ok: false, status: 401, error: 'invalid read key' } | |
| 84 | return { ok: true } | |
| 85 | } | |
| 86 | ||
| 87 | /** | |
| 88 | * How often a key's last-used stamp is written. | |
| 89 | * | |
| 90 | * The stamp is there so somebody can see whether a key is still in use before | |
| 91 | * revoking it, which a minute's resolution answers. Writing on every request | |
| 92 | * would put an UPDATE in front of every read of a log stream that a dashboard | |
| 93 | * polls. | |
| 94 | */ | |
| 95 | export const LAST_USED_RESOLUTION_MS = 60_000 | |
| 96 | ||
| 97 | export function shouldRecordUse(lastUsedAt: string | Date | null | undefined, now: Date): boolean { | |
| 98 | if (!lastUsedAt) | |
| 99 | return true | |
| 100 | const previous = lastUsedAt instanceof Date ? lastUsedAt.getTime() : Date.parse(String(lastUsedAt)) | |
| 101 | if (Number.isNaN(previous)) | |
| 102 | return true | |
| 103 | return now.getTime() - previous >= LAST_USED_RESOLUTION_MS | |
| 104 | } | |
| @@ -14,6 +14,8 @@ | ||
| 14 | 14 | */ |
| 15 | 15 | import { Auth } from '@stacksjs/auth' |
| 16 | 16 | import { config } from '@stacksjs/config' |
| 17 | import { db } from '@stacksjs/database' | |
| 18 | import { authorizeRead, looksLikeReadKey, shouldRecordUse } from './read-key' | |
| 17 | 19 | |
| 18 | 20 | export async function userFromRequest(request: any): Promise<any | null> { |
| 19 | 21 | const authHeader = request.headers?.get?.('authorization') ?? '' |
| @@ -37,3 +39,86 @@ export async function userFromRequest(request: any): Promise<any | null> { | ||
| 37 | 39 | return null |
| 38 | 40 | } |
| 39 | 41 | } |
| 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 | */ | |
| 50 | function 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 | ||
| 61 | export 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 | */ | |
| 79 | export 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. */ | |
| 117 | async 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 | } | |
| @@ -0,0 +1,25 @@ | ||
| 1 | -- SQLITE DIALECT, WRITTEN BY HAND. See stacksjs/stacks#2346. | |
| 2 | -- | |
| 3 | -- A read-scoped project key, so a machine can read this project's logs without | |
| 4 | -- holding a person's credentials (loghqorg/loghq#14). | |
| 5 | -- | |
| 6 | -- `ingest_key` beside it is public and write-only: it ships in client bundles | |
| 7 | -- and only permits writing to one project. This one returns data, so it is a | |
| 8 | -- secret, it is compared in constant time (app/Support/read-key.ts), and the | |
| 9 | -- ingest route never accepts it. | |
| 10 | -- | |
| 11 | -- Both columns are nullable because a key is issued on demand rather than at | |
| 12 | -- creation. A project that never needs one never has one, and revoking is | |
| 13 | -- setting the column back to NULL — which is also why the uniqueness below is | |
| 14 | -- a partial index: every revoked project would otherwise collide on NULL. | |
| 15 | -- | |
| 16 | -- `read_key_last_used_at` exists so somebody can tell whether a key is still | |
| 17 | -- in use before revoking it. It is written at most once a minute per key | |
| 18 | -- (LAST_USED_RESOLUTION_MS), not on every request: the dashboard polls the log | |
| 19 | -- stream, and an UPDATE in front of every poll would cost more than the stamp | |
| 20 | -- is worth. | |
| 21 | -- | |
| 22 | -- Numbered 24, continuing past 0000000023. | |
| 23 | ALTER TABLE "projects" ADD COLUMN "read_key" TEXT; | |
| 24 | ALTER TABLE "projects" ADD COLUMN "read_key_last_used_at" TEXT; | |
| 25 | CREATE UNIQUE INDEX IF NOT EXISTS "projects_read_key_unique" ON "projects" ("read_key") WHERE "read_key" IS NOT NULL; | |
| @@ -116,7 +116,7 @@ if (user) { | ||
| 116 | 116 | try { |
| 117 | 117 | // Owned projects only — settings is the owner's admin surface. |
| 118 | 118 | __projectRows = (await __db.unsafe( |
| 119 | 'SELECT id, name, platform, ingest_key, is_active FROM projects WHERE owner_id = $1 ORDER BY created_at DESC NULLS LAST, id', | |
| 119 | 'SELECT id, name, platform, ingest_key, is_active, read_key, read_key_last_used_at FROM projects WHERE owner_id = $1 ORDER BY created_at DESC NULLS LAST, id', | |
| 120 | 120 | [Number(user.id)], |
| 121 | 121 | )) ?? [] |
| 122 | 122 | if (__projectRows.length === 0) |
| @@ -158,8 +158,8 @@ if (user) { | ||
| 158 | 158 | |
| 159 | 159 | // Template-facing projections. `ingest_key` is dropped here and nowhere else: |
| 160 | 160 | // the switcher needs a project's id, name and state, never its credential. |
| 161 | const projects = __projectRows.map(({ ingest_key: _ignored, ...p }) => p) | |
| 162 | const activeProject = __activeRow ? (({ ingest_key: _ignored, ...p }) => p)(__activeRow) : null | |
| 161 | const projects = __projectRows.map(({ ingest_key: _ignored, read_key: _secret, ...p }) => p) | |
| 162 | const activeProject = __activeRow ? (({ ingest_key: _ignored, read_key: _secret, ...p }) => p)(__activeRow) : null | |
| 163 | 163 | const projectId = activeProject?.id || null |
| 164 | 164 | |
| 165 | 165 | // The connected repository, for the Repository section below. Loaded here |
| @@ -253,6 +253,12 @@ const inviteView = __inviteRows.map(i => ({ id: i.id, email: i.email, registered | ||
| 253 | 253 | // named value, so publishing it stays a decision rather than a side effect. |
| 254 | 254 | const keyFull = __activeRow ? String(__activeRow.ingest_key || '') : '' |
| 255 | 255 | const keyMask = maskKey(keyFull) |
| 256 | // The read key itself never reaches the page: it is a secret, shown once when | |
| 257 | // it is issued and never again (#14). Only whether one exists, and when it was | |
| 258 | // last used, so somebody can tell a live key from a forgotten one before | |
| 259 | // revoking it. | |
| 260 | const hasReadKeyInitial = !!(__activeRow && __activeRow.read_key) | |
| 261 | const readKeyUsedInitial = __activeRow && __activeRow.read_key_last_used_at ? String(__activeRow.read_key_last_used_at) : '' | |
| 256 | 262 | |
| 257 | 263 | // Framework-specific install snippet, keyed off the project's platform. Key-only |
| 258 | 264 | // (Flare-style): the ingest key alone identifies the project — no project id, no DSN. |
| @@ -539,6 +545,8 @@ defineClientPayload({ | ||
| 539 | 545 | codeText: guide.code, |
| 540 | 546 | initialKeyFull: keyFull, |
| 541 | 547 | initialKeyMask: keyMask, |
| 548 | hasReadKeyInitial, | |
| 549 | readKeyUsedInitial, | |
| 542 | 550 | }) |
| 543 | 551 | |
| 544 | 552 | useSeoMeta({ |
| @@ -828,6 +836,88 @@ async function rotateKey(): Promise<void> { | ||
| 828 | 836 | } |
| 829 | 837 | } |
| 830 | 838 | |
| 839 | // ---- read key ---- | |
| 840 | // Issued on demand, shown once, and revocable (#14). The key is held in a | |
| 841 | // signal only for as long as the page that issued it is open: it is never | |
| 842 | // rendered from the server and nothing stores it, so closing the page is the | |
| 843 | // end of the only copy the browser ever had. | |
| 844 | const hasReadKey = state(hasReadKeyInitial) | |
| 845 | const readKeyUsed = state(readKeyUsedInitial) | |
| 846 | const readKeyOnce = state('') | |
| 847 | const readKeyBusy = state(false) | |
| 848 | const readKeyNote = state('') | |
| 849 | const readKeyNoteOk = state(true) | |
| 850 | const copiedReadKey = state(false) | |
| 851 | ||
| 852 | /** "never", or the stamp as the browser's locale writes it. */ | |
| 853 | const readKeyUsedLabel = (): string => { | |
| 854 | const at = readKeyUsed() | |
| 855 | if (!at) | |
| 856 | return 'never used' | |
| 857 | const when = new Date(at) | |
| 858 | return Number.isNaN(when.getTime()) ? 'used recently' : `last used ${when.toLocaleString()}` | |
| 859 | } | |
| 860 | ||
| 861 | function copyReadKey(): void { | |
| 862 | void copyText(readKeyOnce(), copiedReadKey) | |
| 863 | } | |
| 864 | ||
| 865 | async function issueReadKey(): Promise<void> { | |
| 866 | // Replacing one is destructive in the same way rotating the ingest key is, | |
| 867 | // so it asks — but only when there is something to break. | |
| 868 | if (hasReadKey()) { | |
| 869 | const ask = window.stxConfirm | |
| 870 | ? window.stxConfirm('The current read key stops working immediately, and anything still using it will get 404 until you update it. Continue?', { title: 'Replace read key?', confirmText: 'Replace key', cancelText: 'Cancel', type: 'warning' }) | |
| 871 | : Promise.resolve(true) | |
| 872 | if (!(await Promise.resolve(ask))) | |
| 873 | return | |
| 874 | } | |
| 875 | readKeyBusy.set(true) | |
| 876 | try { | |
| 877 | const res = await apiFetch(`${projUrl}/read-key`, { method: 'POST' }) | |
| 878 | const body = await readJson(res) | |
| 879 | readKeyBusy.set(false) | |
| 880 | if (!res.ok) { | |
| 881 | note(readKeyNote, readKeyNoteOk, body.error || 'Could not issue a key.', false) | |
| 882 | return | |
| 883 | } | |
| 884 | readKeyOnce.set(String(body.read_key || '')) | |
| 885 | hasReadKey.set(true) | |
| 886 | readKeyUsed.set('') | |
| 887 | note(readKeyNote, readKeyNoteOk, 'Copy it now. This is the only time it is shown.', true) | |
| 888 | } | |
| 889 | catch { | |
| 890 | readKeyBusy.set(false) | |
| 891 | note(readKeyNote, readKeyNoteOk, 'Network error.', false) | |
| 892 | } | |
| 893 | } | |
| 894 | ||
| 895 | async function revokeReadKey(): Promise<void> { | |
| 896 | const ask = window.stxConfirm | |
| 897 | ? window.stxConfirm('Anything using this key stops reading immediately. Continue?', { title: 'Revoke read key?', confirmText: 'Revoke', cancelText: 'Cancel', type: 'warning' }) | |
| 898 | : Promise.resolve(true) | |
| 899 | if (!(await Promise.resolve(ask))) | |
| 900 | return | |
| 901 | readKeyBusy.set(true) | |
| 902 | try { | |
| 903 | const res = await apiFetch(`${projUrl}/read-key`, { method: 'DELETE' }) | |
| 904 | const body = await readJson(res) | |
| 905 | readKeyBusy.set(false) | |
| 906 | if (!res.ok) { | |
| 907 | note(readKeyNote, readKeyNoteOk, body.error || 'Could not revoke.', false) | |
| 908 | return | |
| 909 | } | |
| 910 | hasReadKey.set(false) | |
| 911 | readKeyUsed.set('') | |
| 912 | readKeyOnce.set('') | |
| 913 | note(readKeyNote, readKeyNoteOk, 'Revoked.', true) | |
| 914 | } | |
| 915 | catch { | |
| 916 | readKeyBusy.set(false) | |
| 917 | note(readKeyNote, readKeyNoteOk, 'Network error.', false) | |
| 918 | } | |
| 919 | } | |
| 920 | ||
| 831 | 921 | // ---- members and invites ---- |
| 832 | 922 | const inviteEmail = state('') |
| 833 | 923 | const inviting = state(false) |
| @@ -1254,6 +1344,7 @@ onMount(() => { | ||
| 1254 | 1344 | <nav class="flex flex-wrap gap-2 items-center mb-8 text-sm"> |
| 1255 | 1345 | <a href="#install" class="px-3 py-1.5 font-medium text-muted hover:text-ink border border-line rounded-[999px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Install</a> |
| 1256 | 1346 | <a href="#keys" class="px-3 py-1.5 font-medium text-muted hover:text-ink border border-line rounded-[999px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Keys</a> |
| 1347 | <a href="#read-key" class="px-3 py-1.5 font-medium text-muted hover:text-ink border border-line rounded-[999px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Read key</a> | |
| 1257 | 1348 | <a href="#repository" class="px-3 py-1.5 font-medium text-muted hover:text-ink border border-line rounded-[999px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Repository</a> |
| 1258 | 1349 | <a href="#members" class="px-3 py-1.5 font-medium text-muted hover:text-ink border border-line rounded-[999px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Members</a> |
| 1259 | 1350 | <a href="#alerts" class="px-3 py-1.5 font-medium text-muted hover:text-ink border border-line rounded-[999px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Alerts</a> |
| @@ -1316,6 +1407,45 @@ onMount(() => { | ||
| 1316 | 1407 | </div> |
| 1317 | 1408 | </section> |
| 1318 | 1409 | |
| 1410 | {{-- ========================= READ KEY ========================= --}} | |
| 1411 | <section id="read-key" class="mb-10 scroll-mt-[84px]"> | |
| 1412 | <div class="mb-3 font-semibold text-[13px] text-subtle tracking-[0.04em] uppercase">Read key</div> | |
| 1413 | <div class="px-5 py-5 app-panel"> | |
| 1414 | <p class="mb-4 text-sm text-subtle"> | |
| 1415 | Lets a server read this project's logs without signing in as you: a Stacks dashboard, or anything else reading the API machine-to-machine. | |
| 1416 | Unlike the API key above this one <em>is</em> a secret: it reads your logs, it belongs to no account, and it keeps working after you leave. | |
| 1417 | Send it as <span class="font-mono">Authorization: Bearer</span>. | |
| 1418 | </p> | |
| 1419 | ||
| 1420 | {{-- Just issued: the only time the key is ever shown. --}} | |
| 1421 | <div :show="!!readKeyOnce()"> | |
| 1422 | <div class="flex flex-wrap gap-2 items-center"> | |
| 1423 | <code class="flex-1 px-3.5 py-2.5 min-w-[220px] font-mono text-ink text-sm tracking-[0.01em] truncate bg-canvas border border-line rounded-[10px]" :text="readKeyOnce()"></code> | |
| 1424 | <button @click="copyReadKey" class="btn-quiet btn-sm" :text="copiedReadKey() ? 'Copied' : 'Copy'">Copy</button> | |
| 1425 | </div> | |
| 1426 | <p class="mt-3.5 leading-relaxed text-warn text-xs"> | |
| 1427 | Copy it now. It is not stored anywhere you can read it back, so leaving this page means issuing a new one. | |
| 1428 | </p> | |
| 1429 | </div> | |
| 1430 | ||
| 1431 | {{-- Otherwise: whether one exists, and whether it is still in use. --}} | |
| 1432 | <div :show="!readKeyOnce()"> | |
| 1433 | <div class="flex flex-wrap gap-2 items-center"> | |
| 1434 | <span class="flex-1 min-w-[220px] text-sm" :class="hasReadKey() ? 'text-ink' : 'text-subtle'" :text="hasReadKey() ? `A read key is active, ${readKeyUsedLabel()}.` : 'No read key. Issue one to let a server read this project.'"></span> | |
| 1435 | <Button variant="ghost" @click="issueReadKey" :loading="readKeyBusy()" :label="hasReadKey() ? 'Replace' : 'Issue key'" className="btn-quiet btn-sm">Issue key</Button> | |
| 1436 | <span :show="hasReadKey()"> | |
| 1437 | <Button variant="ghost" @click="revokeReadKey" :loading="readKeyBusy()" className="btn-danger btn-sm">Revoke</Button> | |
| 1438 | </span> | |
| 1439 | </div> | |
| 1440 | <p class="mt-3.5 leading-relaxed text-subtle text-xs"> | |
| 1441 | The last-used time is written at most once a minute, so a key in constant use shows a time up to a minute old. | |
| 1442 | </p> | |
| 1443 | </div> | |
| 1444 | ||
| 1445 | <span class="mt-2 text-ok text-xs" :show="!!readKeyNote()" :class="readKeyNoteOk() ? 'mt-2 text-xs text-ok' : 'mt-2 text-xs text-accent'">{{ readKeyNote() }}</span> | |
| 1446 | </div> | |
| 1447 | </section> | |
| 1448 | ||
| 1319 | 1449 | {{-- ========================== REPOSITORY ========================== --}} |
| 1320 | 1450 | <section id="repository" class="mb-10 scroll-mt-[84px]"> |
| 1321 | 1451 | <div class="mb-3 font-semibold text-[13px] text-subtle tracking-[0.04em] uppercase">Repository</div> |
| @@ -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 }) |
| @@ -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. |
| @@ -0,0 +1,120 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import { authorizeRead, LAST_USED_RESOLUTION_MS, looksLikeReadKey, newReadKey, sameKey, shouldRecordUse } from '../../app/Support/read-key' | |
| 3 | ||
| 4 | const KEY = newReadKey() | |
| 5 | const project = { id: 'demo', read_key: KEY, is_active: true } | |
| 6 | ||
| 7 | describe('newReadKey', () => { | |
| 8 | test('is prefixed and carries 256 bits of hex', () => { | |
| 9 | expect(KEY.startsWith('lhq_read_')).toBe(true) | |
| 10 | expect(KEY.slice('lhq_read_'.length)).toMatch(/^[0-9a-f]{64}$/) | |
| 11 | }) | |
| 12 | ||
| 13 | test('does not repeat', () => { | |
| 14 | const keys = new Set(Array.from({ length: 200 }, () => newReadKey())) | |
| 15 | expect(keys.size).toBe(200) | |
| 16 | }) | |
| 17 | }) | |
| 18 | ||
| 19 | describe('looksLikeReadKey', () => { | |
| 20 | test('accepts a key it minted', () => { | |
| 21 | expect(looksLikeReadKey(KEY)).toBe(true) | |
| 22 | }) | |
| 23 | ||
| 24 | test('rejects anything else a bearer header might carry', () => { | |
| 25 | // The prefix is what keeps a read key out of Auth.getUserFromToken, so a | |
| 26 | // user's token and an ingest key must never pass for one. | |
| 27 | for (const value of [ | |
| 28 | '', | |
| 29 | null, | |
| 30 | undefined, | |
| 31 | 'loghq_5f3a19b7c4d2e8091ab12cd34ef56ab7', | |
| 32 | 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig', | |
| 33 | `${'lhq_read_'}${'z'.repeat(64)}`, | |
| 34 | `${'lhq_read_'}${'a'.repeat(63)}`, | |
| 35 | `${'lhq_read_'}${'a'.repeat(65)}`, | |
| 36 | KEY.toUpperCase(), | |
| 37 | ]) | |
| 38 | expect(looksLikeReadKey(value)).toBe(false) | |
| 39 | }) | |
| 40 | }) | |
| 41 | ||
| 42 | describe('sameKey', () => { | |
| 43 | test('matches an identical key and nothing else', () => { | |
| 44 | expect(sameKey(KEY, KEY)).toBe(true) | |
| 45 | expect(sameKey(KEY, newReadKey())).toBe(false) | |
| 46 | }) | |
| 47 | ||
| 48 | test('is false on a shared prefix, not just a differing first byte', () => { | |
| 49 | const other = `${KEY.slice(0, -1)}${KEY.endsWith('a') ? 'b' : 'a'}` | |
| 50 | expect(sameKey(other, KEY)).toBe(false) | |
| 51 | }) | |
| 52 | ||
| 53 | test('does not throw on a length mismatch', () => { | |
| 54 | expect(sameKey('short', KEY)).toBe(false) | |
| 55 | expect(sameKey(KEY, 'short')).toBe(false) | |
| 56 | }) | |
| 57 | }) | |
| 58 | ||
| 59 | describe('authorizeRead', () => { | |
| 60 | test('allows the project its own key', () => { | |
| 61 | expect(authorizeRead(project, KEY)).toEqual({ ok: true }) | |
| 62 | }) | |
| 63 | ||
| 64 | test('refuses an unknown project with 404', () => { | |
| 65 | expect(authorizeRead(null, KEY)).toEqual({ ok: false, status: 404, error: 'unknown project' }) | |
| 66 | expect(authorizeRead(undefined, KEY)).toEqual({ ok: false, status: 404, error: 'unknown project' }) | |
| 67 | }) | |
| 68 | ||
| 69 | test('refuses an inactive project with 403, however is_active is stored', () => { | |
| 70 | // sqlite hands booleans back as 0/1. | |
| 71 | expect(authorizeRead({ ...project, is_active: false }, KEY).ok).toBe(false) | |
| 72 | expect(authorizeRead({ ...project, is_active: 0 }, KEY)).toEqual({ ok: false, status: 403, error: 'project inactive' }) | |
| 73 | }) | |
| 74 | ||
| 75 | test('refuses a project with no key issued, whatever is presented', () => { | |
| 76 | // Guards `null === null`: no key configured must never authorise, not even | |
| 77 | // against a caller presenting nothing. | |
| 78 | for (const presented of [KEY, null, undefined, '']) | |
| 79 | expect(authorizeRead({ ...project, read_key: null }, presented).ok).toBe(false) | |
| 80 | expect(authorizeRead({ ...project, read_key: null }, KEY).status).toBe(403) | |
| 81 | }) | |
| 82 | ||
| 83 | test('refuses another project\'s key with 401', () => { | |
| 84 | expect(authorizeRead(project, newReadKey())).toEqual({ ok: false, status: 401, error: 'invalid read key' }) | |
| 85 | }) | |
| 86 | ||
| 87 | test('refuses an ingest key, which grants writes and must not grant reads', () => { | |
| 88 | expect(authorizeRead({ ...project, read_key: KEY }, 'loghq_5f3a19b7c4d2e8091ab12cd34ef56ab7').ok).toBe(false) | |
| 89 | }) | |
| 90 | ||
| 91 | test('refuses a stored value that is not a well-formed key', () => { | |
| 92 | // A column hand-edited to something short must not become a weak key. | |
| 93 | expect(authorizeRead({ ...project, read_key: 'x' }, 'x').ok).toBe(false) | |
| 94 | }) | |
| 95 | }) | |
| 96 | ||
| 97 | describe('shouldRecordUse', () => { | |
| 98 | const now = new Date('2026-10-19T12:00:00.000Z') | |
| 99 | ||
| 100 | test('records the first use', () => { | |
| 101 | expect(shouldRecordUse(null, now)).toBe(true) | |
| 102 | expect(shouldRecordUse(undefined, now)).toBe(true) | |
| 103 | }) | |
| 104 | ||
| 105 | test('records again once the resolution has passed, and not before', () => { | |
| 106 | const justBefore = new Date(now.getTime() - LAST_USED_RESOLUTION_MS + 1).toISOString() | |
| 107 | const atResolution = new Date(now.getTime() - LAST_USED_RESOLUTION_MS).toISOString() | |
| 108 | expect(shouldRecordUse(justBefore, now)).toBe(false) | |
| 109 | expect(shouldRecordUse(atResolution, now)).toBe(true) | |
| 110 | }) | |
| 111 | ||
| 112 | test('records when the stored stamp cannot be read', () => { | |
| 113 | // Better an extra write than a key that never looks used. | |
| 114 | expect(shouldRecordUse('not a date', now)).toBe(true) | |
| 115 | }) | |
| 116 | ||
| 117 | test('accepts a Date as well as a string', () => { | |
| 118 | expect(shouldRecordUse(new Date(now.getTime() - 1000), now)).toBe(false) | |
| 119 | }) | |
| 120 | }) | |