feat(auth): local accounts, teams, permissions and API keys - #200
Conversation
…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.
…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>
|
Reviewed this end to end: read the diff, ran the suite with The quality bar here is high. Argon2id with sane parameters, 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 —
|
Summary
First step of #196: a native authentication and authorization layer, DependencyTrack style.
event:read,event:write,catalog:read,catalog:write,lock:read,lock:write,links:read,links:write,access:manage). Built-inAdministratorsteam and initialadminaccount created at first start.Administratorsteam acts as an administrator, like a global key.401, authenticated callers without the permission get403.AuthService(/api/v1alpha1/auth/*) for users, teams and API keys, plus login/logout/password endpoints.tracker_auth_requests_total{principal,result}andtracker_auth_logins_total{method,result}.AUTH_PUBLIC_URLor 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.docs/AUTHENTICATION.md, newAuthenticationsection indocs/CONFIGURATION.md,AUTH_*block in.env.example.Compatibility
No behaviour change for existing installations: without
AUTH_ANONYMOUS_PERMISSIONS, anonymous callers keep every permission exceptaccess:manageand the server logs a warning.DEMO_MODE=truerestricts anonymous callers to read permissions. The default will become empty in a later major release.Two side fixes surfaced by the new tests:
Event.DeleteEventnever implemented the generatedEventServiceServerinterface (the method isDeleteEvents), so the RPC always answeredUnimplementedonmain. It is renamed and now works.generated/proto/event/v1alpha1/event.pb.gohad been hand-edited to addwaiting_approval = 14without updating the raw descriptor; it is regenerated withbuf generatein a dedicated commit.google.golang.org/grpcis bumped from v1.79.3 to v1.83.2: the Snyk check flags GO-2026-6061 on this PR becausego.modchanges, andmaincarries the same vulnerable version. The gosec CI step now skips generated code, whose*ApiKey*_FullMethodNameconstants trip G101.Helm chart, docker-compose and MCP server are untouched on purpose; the
AUTH_*variables go through the free-formenvblock ofvalues.yamlfor now.Follow-ups (separate PRs)
feat!: anonymous default becomes empty.Testing
MONGO_TEST_URI,mongo:7service added to the CI workflow).Refs #196