Skip to content

feat(auth): local accounts, teams, permissions and API keys - #200

Merged
jplanckeel merged 39 commits into
BananaOps:mainfrom
TartanLeGrand:feat/auth-core
Sep 23, 2026
Merged

jplanckeel merged 39 commits into
BananaOps:mainfrom
TartanLeGrand:feat/auth-core

Conversation

@TartanLeGrand

@TartanLeGrand TartanLeGrand commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

First step of #196: a native authentication and authorization layer, DependencyTrack style.

  • Local accounts (Argon2id), cookie sessions (HS256 JWT, HttpOnly, SameSite=Lax), login rate limiting.
  • Teams carrying permissions (event:read, event:write, catalog:read, catalog:write, lock:read, lock:write, links:read, links:write, access:manage). Built-in Administrators team and initial admin account created at first start.
  • API keys per team, plus global keys for administrators. Secrets shown once, stored hashed. A key attached to the Administrators team acts as an administrator, like a global key.
  • Every gRPC method and REST route is mapped to a permission; unmapped methods are refused. Anonymous callers get 401, authenticated callers without the permission get 403.
  • New AuthService (/api/v1alpha1/auth/*) for users, teams and API keys, plus login/logout/password endpoints.
  • Prometheus counters tracker_auth_requests_total{principal,result} and tracker_auth_logins_total{method,result}.
  • Browser cross-site requests (Sec-Fetch-Site / Origin check against AUTH_PUBLIC_URL or the request host) cannot use the session cookie, and login refuses cross-site posts; API keys and gRPC clients are unaffected. Logout is stateless: a session stays valid until its expiry or a password change.
  • Documentation: docs/AUTHENTICATION.md, new Authentication section in docs/CONFIGURATION.md, AUTH_* block in .env.example.

Compatibility

No behaviour change for existing installations: without AUTH_ANONYMOUS_PERMISSIONS, anonymous callers keep every permission except access:manage and the server logs a warning. DEMO_MODE=true restricts anonymous callers to read permissions. The default will become empty in a later major release.

Two side fixes surfaced by the new tests:

  • Event.DeleteEvent never implemented the generated EventServiceServer interface (the method is DeleteEvents), so the RPC always answered Unimplemented on main. It is renamed and now works.
  • generated/proto/event/v1alpha1/event.pb.go had been hand-edited to add waiting_approval = 14 without updating the raw descriptor; it is regenerated with buf generate in a dedicated commit.

google.golang.org/grpc is bumped from v1.79.3 to v1.83.2: the Snyk check flags GO-2026-6061 on this PR because go.mod changes, and main carries the same vulnerable version. The gosec CI step now skips generated code, whose *ApiKey*_FullMethodName constants trip G101.

Helm chart, docker-compose and MCP server are untouched on purpose; the AUTH_* variables go through the free-form env block of values.yaml for now.

Follow-ups (separate PRs)

  1. Web UI: login page, profile, administration screens.
  2. OIDC login with group to team mapping.
  3. Per-service team scope enforced on events, locks and catalog.
  4. feat!: anonymous default becomes empty.

Testing

  • Unit tests for permissions, passwords, API keys, sessions, config, rate limiting (bounded memory, proxy-appended client IP), cross-site guard, concurrent bootstrap, authorization table (every registered RPC is mapped and every mapped entry is a registered RPC, checked through protoregistry), gateway method resolution.
  • Store and service tests against MongoDB (MONGO_TEST_URI, mongo:7 service added to the CI workflow).
  • End-to-end run of the container image: anonymous 401/200 split, login, password change, team and key creation, API key on REST and gRPC, revoked key, rate limiting, metrics, demo mode, transitional default (18 checks).

Refs #196

…ting

The first entry of X-Forwarded-For is client controlled. Behind an ingress
that appends the peer address (nginx, Traefik, HAProxy), an attacker could
send an arbitrary first entry and get a new rate limiting key on every
request, defeating the 5 failures per 60 s per (username, IP) budget.
ClientIP now reads the last non empty entry, the one written by the trusted
proxy.
The failures map was only pruned for the key being looked at, so a caller
varying the username or the IP created keys that were never reclaimed. A
full sweep now runs from the locked paths, every 1000 recorded failures or
every 60 s of limiter clock, whichever comes first.
The session cookie is SameSite=Lax, so a browser still sends it on a top
level cross-site GET, and UnLock is bound to GET /api/v1alpha1/unlock/{id}.
A third party link could therefore release a lock as the logged in user, and
a cross-site form could POST to the login endpoint.

IsCrossSite reads Sec-Fetch-Site, falling back to comparing Origin with
AUTH_PUBLIC_URL or the request host. HTTPMiddleware drops a cookie
credential on such a request, failing closed to anonymous rather than to
403 so public GET routes keep working. The login handler answers 403 before
any password work. Explicit credentials (API key, bearer) are untouched, and
non browser clients sending neither header are unaffected.
Spec 5.4 asks for a login counter next to the authorization one. It is
incremented at the three exits of the login handler: success, failure
(unknown user, ineligible account, wrong password) and rate_limited. The
method label is local, leaving room for oidc.
CreateEvent calls CreateLock and UpdateLock on the request context, so the
method name authz resolves stays CreateEvent. The nested lock operation is
authorized by event:write rather than lock:write, and
tracker_auth_requests_total counts such a request twice. Comment only.
Two replicas starting on an empty database both tried to create the
Administrators team and the admin user, and the loser died on log.Fatalf.
The Helm chart allows several replicas. ErrAlreadyExists on the team now
triggers a read back by name, and on the admin user it means a peer got
there first: AdminCreated is false and no password is returned, so nothing
is logged.
resolveAPIKey dropped the admin flag returned by Effective, so a key
attached to the built-in team held every permission but had IsAdmin false
and could not mint a global key. That contradicted spec 4.1 and the comment
on auth.Principal.IsAdmin. A team key now inherits the flag the same way a
user of that team does.
…ample

access:manage is full administrative control, since it allows joining
Administrators or minting a key on that team. Logout is stateless, so a
stolen token lives until its expiry unless the session version is bumped. An
invalid, revoked or expired API key silently falls back to anonymous, which
under the transitional default hides revocation from the client.

.env.example set AUTH_ANONYMOUS_PERMISSIONS to an empty value, and an
explicitly set variable wins, so copying the file removed every anonymous
permission while the login UI only lands in PR 2. The line is now commented
out.
Browsers omit the default port in Origin, but AUTH_PUBLIC_URL and the Host
header may carry it. Comparing the raw strings made
AUTH_PUBLIC_URL=https://tracker.example.com:443 mismatch
Origin: https://tracker.example.com, which silently turned legitimate cookie
requests anonymous. Both sides are now lowercased and stripped of an
explicit :80 on http or :443 on https. Any other port still has to match.
Move the test-only LoginLimiter.size helper into the test file so the
unused linter no longer flags it (golangci-lint runs with tests: false),
and annotate three gosec false positives: the X-Api-Key header name and
the auth_api_keys collection name are not credentials (G101), and the
session cookie Secure flag is configuration driven because a plain http
deployment cannot set it (G124); HttpOnly and SameSite stay hard-coded.
protoc-gen-go-grpc emits *_FullMethodName constants whose names contain
ApiKey, which gosec G101 reports as hardcoded credentials. Generated files
cannot carry #nosec annotations, so skip them in the scanner.
v1.79.3 is affected by GO-2026-6061 (fixed in v1.82.1). The Snyk check
on this PR reports it because the manifest changed; main carries the
same version.
jplanckeel and others added 2 commits September 23, 2026 14:44
…mous

An API key or bearer token that is malformed, unknown, revoked or expired
resolved to the anonymous principal, so the caller silently inherited
AUTH_ANONYMOUS_PERMISSIONS. Under the transitional default that is wider than
most credentials carry: revoking a key limited to event:read promoted it to
event:write instead of shutting it down, and the client never learned its key
was dead.

Such a request now resolves to auth.RejectedCredential and authorization
answers 401 on every route, public ones included. The check lives in
authz.CheckPermission, so the gRPC services, the gateway and the hand-written
/api/links and /api/homer-links routes are all covered, and the decision is
still counted in tracker_auth_requests_total.

The session cookie keeps its fallback to anonymous: it is ambient, a browser
keeps sending a stale one on its own, and a 401 there would also cover the SPA
and the login page the user needs to recover. The same token presented as an
Authorization: Bearer header is refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Up to 0.21.x the route answered 501: the server method was named DeleteEvent
while the generated EventServiceServer interface declares DeleteEvents, so the
implementation never satisfied it and the embedded unimplemented stub replied
to every call. The rename in this branch makes the route destructive, which
nothing in the branch announced.

BREAKING CHANGE: DELETE /api/v1alpha1/event/{id} used to answer 501
Unimplemented and delete nothing. It now deletes the event. Callers that
relied on the no-op, cleanup scripts, CI jobs or crawlers, must be checked
before upgrading. Deletion requires event:write, which the transitional
AUTH_ANONYMOUS_PERMISSIONS default grants to anonymous callers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jplanckeel-ep

Copy link
Copy Markdown
Contributor

Reviewed this end to end: read the diff, ran the suite with -race against a real MongoDB, and exercised the auth flows against a running server (login, cookie, rate limit, cross-site guard, API keys, locked-down config).

The quality bar here is high. Argon2id with sane parameters, DummyVerify against username enumeration by timing, JWT pinned with WithValidMethods and WithExpirationRequired, API keys stored as SHA-256 with constant-time compare — and X-Forwarded-For reading the last entry, which is the detail almost everyone gets wrong. TestExistingServicesAreGuarded and methods_test.go are excellent safety nets: a new RPC cannot ship unguarded or unmapped.

I pushed two commits to the branch rather than leaving them as review comments, since one of them is a security fix. Happy to drop either if you disagree.

1441948 — fix(auth): refuse invalid credentials instead of downgrading to anonymous

An API key that is malformed, unknown, revoked or expired resolved to the anonymous principal, so the caller inherited AUTH_ANONYMOUS_PERMISSIONS. Under the transitional default that is wider than most keys carry, so revocation was an escalation rather than a shutdown. Reproduced on a running server:

key limited to event:read, valid    → read 200   write 403
same key, after revocation          → read 200   write 200   ← gained event:write

The fix resolves such a request to auth.RejectedCredential and refuses it with 401 in authz.CheckPermission. Putting it there rather than in the transport keeps the single decision point the package is built around: the services, the gateway and the hand-written /api/links and /api/homer-links routes are all covered, and the decision still lands in tracker_auth_requests_total.

The session cookie deliberately keeps its fallback to anonymous. It is ambient, a browser keeps sending a stale one on its own, and a 401 there would also cover the SPA and the login page the user needs to recover — I used the Credentials.FromCookie distinction the code already models. The same token in an Authorization: Bearer header is refused.

revoked key    read 401   write 401   /auth/me 401   /api/links 401
stale cookie   GET / 200  read 200 (anonymous)
same token as bearer      401
no credential  unchanged

2e2d1a5 — docs(events): flag that DELETE /event/{id} now deletes for real

This one deserves its own callout in the PR description. On main the route answers 501 Unimplemented and deletes nothing: the method is named DeleteEvent while the generated interface declares DeleteEvents, so it never satisfied the interface and the embedded stub replied. Verified by running both binaries side by side:

main   DELETE /api/v1alpha1/event/{id} → 501 "method DeleteEvents not implemented", event survives
PR     DELETE /api/v1alpha1/event/{id} → 200, event deleted

It is a real fix, but it turns a previously harmless no-op into a destructive endpoint, reachable anonymously under the transitional default. Anything that called it — cleanup scripts, CI jobs, crawlers — now destroys data. The commit carries a BREAKING CHANGE: footer so release-please surfaces it; right now none of the 37 commits would put it in the CHANGELOG.

Left open, your call

  • The cross-site guard is a no-op under the default config. The mechanism is correct, but dropping the cookie lands on an anonymous principal that holds every write permission, so a cross-site request still gets event:write. It only bites once AUTH_ANONYMOUS_PERMISSIONS is narrowed. Refusing a cross-site request outright on state-changing methods, the way POST /auth/login already does, would close it. The underlying cause is GET /api/v1alpha1/unlock/{id} being a write behind a GET — worth a separate issue.
  • The principal is resolved on every HTTP request, static assets included. auth.HTTPMiddleware wraps the SPA file server, so with a session cookie each .js/.css/.png costs a JWT verify plus two Mongo queries. A page load with 30 assets is roughly 60 extra round trips. Skipping the middleware outside /api/, or a short-lived principal cache, would fix it.
  • Smaller: no DeleteUser RPC (intentional?), authz.RequireHTTP hand-concatenates its JSON error, and AuthService is not covered by the TestExistingServicesAreGuarded reflection sweep.

Separately, and not yours to fix: CreateEvent panics with a nil dereference at server/event.go:113 when the JSON body omits attributes. It is on main already — I will open an issue.

CI

Go test (now with a MongoDB service), Go lint, Go Security and Buf lint are green. The two red checks are pre-existing and unrelated:

  • validate-pr-title fails with Resource not accessible by integration — ytanikin/pr-conventional-commits runs with add_label: true and a fork PR's token cannot write labels. The title itself is valid. This will hit every fork PR; needs a fix in the workflow.
  • security/snyk was already failing on a0d2f97, before my push. On the Go side the only finding is GO-2026-5932 (x/crypto/openpgp unmaintained), which has no fix available and is not called — the code only uses argon2 from x/crypto.

Worth rebasing on main before merge: the branch is 3 commits behind (#198, #202, CLAUDE.md). No conflicts expected, the files are disjoint.

🤖 Review assisted by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants