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
9 days ago

Closes #14.

The problem

Every read endpoint authenticated a person. app/Support/request-auth.ts resolved a bearer through Auth.getUserFromToken, so the only credential that worked carried that account's full scope and died when they left. The one machine credential, ingest_key, is write-only by design.

A Stacks dashboard reading this project server-to-server had nothing correct to hold.

What this adds

A project can issue a read key: one project, reads only, no account behind it.

It is treated as a secret, which the ingest key beside it deliberately is not:

ingest keyread key
purposewrite to one projectread one project
secrecypublic, ships in client bundlessecret, server config only
compared===constant time
shownin the project listingonce, when issued
size128 bits256 bits, lhq_read_ prefixed

The prefix does two jobs: secret scanners can match it, and a read key is told apart from a user's token before either is verified — so a read key is never handed to Auth.getUserFromToken.

Constant-time comparison is not ceremony here. === on strings short-circuits at the first differing byte, which leaks the length of a correct guess to anyone who can time the response. That is an attack on a secret somebody can guess at repeatedly, and not one on a public ingest key — which is why only this side does it.

Endpoints

Exactly the two the issue names:

  • GET /api/projects/{projectId}/logs, filters unchanged
  • GET /api/logs/{logId}

Issued and revoked by the owner at POST / DELETE /api/projects/{projectId}/read-key, beside the existing rotate-key.

Two things worth review

A key for another project answers 404, not 403, matching what ownsProject already does: a caller who cannot read a project should not learn it exists.

GET /api/logs/{logId} reads the entry before authorising, then authorises against that entry's project. Doing it the other way — 404 for a missing id before checking the caller — would let an unauthenticated caller discover which log ids are real. With an empty project id for a missing entry, which neither a key nor a user can read, an unauthenticated caller gets 401 either way.

Cost

The last-used stamp is written at most once a minute per key, not per request: the dashboard polls the stream, and an UPDATE in front of every poll would cost more than the stamp is worth.

Checks

18 new unit tests on the authorizer, covering the shapes a bearer header might carry, a project with no key issued, another project's key, a hand-edited column, and the stamp's resolution window. Full unit suite 297 pass / 0 fail, lint clean.

Not included

The settings UI. The credential and its API are what unblocks stacksjs/stacks#2915; issuing a key today is a POST as the owner. Happy to add the settings panel beside the ingest key in a follow-up, or here if you'd rather it land together.

9 changed files on the files tab, with 0 review threads.

Sign in to comment on this pull request.