Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
4304662
chore(auth): add jwt and argon2 dependencies
TartanLeGrand Sep 5, 2026
7c6c067
feat(auth): add permissions and service scope types
TartanLeGrand Sep 5, 2026
6e05068
feat(auth): add principal type and context helpers
TartanLeGrand Sep 5, 2026
755a961
feat(auth): add argon2id password hashing
TartanLeGrand Sep 5, 2026
90c273a
feat(auth): add API key generation and hashing
TartanLeGrand Sep 5, 2026
027c336
feat(auth): add JWT session manager
TartanLeGrand Sep 5, 2026
9a65c19
feat(auth): load AUTH_* configuration from environment
TartanLeGrand Sep 5, 2026
0fe633d
feat(auth): extract credentials from HTTP and gRPC requests
TartanLeGrand Sep 5, 2026
13c2587
feat(auth): add HTTP middleware and gRPC interceptors
TartanLeGrand Sep 5, 2026
1fbddda
feat(auth): add in-memory login rate limiter
TartanLeGrand Sep 5, 2026
30aec2d
feat(auth): add method permission table and authorization checks
TartanLeGrand Sep 5, 2026
49d93c8
style(auth): gofmt config test imports
TartanLeGrand Sep 5, 2026
cb21bab
feat(auth): add user store, auth indexes and shared mongo connection
TartanLeGrand Sep 5, 2026
c2b76ea
feat(auth): add team, API key and settings stores
TartanLeGrand Sep 5, 2026
c98efba
fix(proto): regenerate event descriptor with waiting_approval status
TartanLeGrand Sep 5, 2026
4f95fbc
feat(auth): add AuthService proto and generated code
TartanLeGrand Sep 5, 2026
156f9f9
feat(auth): resolve principals from sessions and API keys
TartanLeGrand Sep 5, 2026
14eaf2c
feat(auth): bootstrap administrators team and initial admin
TartanLeGrand Sep 5, 2026
34f3b57
feat(auth): add login, logout and password change endpoints
TartanLeGrand Sep 5, 2026
23360f1
feat(auth): add AuthService with config, identity and user management
TartanLeGrand Sep 5, 2026
cd1f91e
feat(auth): manage teams and API keys through AuthService
TartanLeGrand Sep 5, 2026
14f9116
feat(auth): wire authentication middleware and guard every endpoint
TartanLeGrand Sep 5, 2026
7609076
fix(auth): resolve RPC method name through the grpc-gateway
TartanLeGrand Sep 5, 2026
70f6592
docs(auth): document authentication and run tests against MongoDB in CI
TartanLeGrand Sep 5, 2026
df150f6
docs(auth): clarify anonymous permission precedence and list API key …
TartanLeGrand Sep 5, 2026
d0fcad9
fix(auth): use the proxy-appended X-Forwarded-For entry for rate limi…
TartanLeGrand Sep 5, 2026
b79a85f
fix(auth): bound the login rate limiter memory
TartanLeGrand Sep 5, 2026
28b00f6
fix(auth): ignore session cookies on cross-site browser requests
TartanLeGrand Sep 5, 2026
bedf079
feat(auth): add tracker_auth_logins_total metric
TartanLeGrand Sep 5, 2026
ab762cf
docs(auth): explain the double authz count in CreateEvent
TartanLeGrand Sep 5, 2026
a2bc40d
fix(auth): tolerate concurrent bootstrap across replicas
TartanLeGrand Sep 5, 2026
d61a1af
fix(auth): grant admin flag to API keys of the Administrators team
TartanLeGrand Sep 5, 2026
e1ec28e
docs(auth): clarify access:manage scope, logout semantics and .env ex…
TartanLeGrand Sep 5, 2026
7de689a
fix(auth): normalize default ports in the cross-site origin check
TartanLeGrand Sep 5, 2026
d334d0a
fix(auth): satisfy golangci-lint and gosec on the auth packages
TartanLeGrand Sep 5, 2026
e404919
ci: exclude generated code from the gosec scan
TartanLeGrand Sep 5, 2026
a0d2f97
fix(deps): bump google.golang.org/grpc to v1.83.2
TartanLeGrand Sep 5, 2026
1441948
fix(auth): refuse invalid credentials instead of downgrading to anony…
jplanckeel Sep 23, 2026
2e2d1a5
docs(events): flag that DELETE /event/{id} now deletes for real
jplanckeel Sep 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,24 @@ SLACK_EVENTS_CHANNEL=
# Homer Dashboard Integration (optional)
# URL of your Homer dashboard to import links from
HOMER_URL=

# Authentication (see docs/AUTHENTICATION.md)
# Comma separated permissions granted to anonymous callers.
# Leave the line commented out to keep the transitional default: every
# permission except access:manage. Setting it wins even when the value is
# empty, and an empty value means no anonymous access at all, which locks the
# UI out until the login screen ships. Uncomment it to pick a narrower set.
# AUTH_ANONYMOUS_PERMISSIONS=event:read,catalog:read,lock:read,links:read
# Password of the initial "admin" account, used only when the user collection is empty.
# Unset: a random password is generated and printed once in the server logs.
AUTH_ADMIN_PASSWORD=
# Base64 encoded secret (32 bytes or more) signing session cookies.
# Unset: generated once and persisted in MongoDB.
AUTH_SESSION_SECRET=
AUTH_SESSION_TTL=12h
# Public URL of the UI, used to decide whether cookies are Secure.
AUTH_PUBLIC_URL=
AUTH_COOKIE_SECURE=false
# Trust the last X-Forwarded-For entry for login rate limiting, only behind a
# reverse proxy you control that appends the peer address to the header.
AUTH_TRUST_PROXY=false
16 changes: 15 additions & 1 deletion .github/workflows/go-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ on:
- go.mod
- go.sum
- main.go
- server/**
- proto/**

jobs:
linting:
Expand Down Expand Up @@ -42,11 +44,21 @@ jobs:
- name: Run Gosec Security Scanner
uses: securego/gosec@master
with:
args: -exclude=G103,G115 ./...
args: -exclude=G103,G115 -exclude-generated ./...

test:
name: Go test
runs-on: ubuntu-latest
services:
mongo:
image: mongo:7
ports:
- 27017:27017
options: >-
--health-cmd "mongosh --quiet --eval 'db.runCommand({ ping: 1 })'"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:

- name: Check out code into the Go module directory
Expand All @@ -61,4 +73,6 @@ jobs:
run: go mod download -x

- name: Test
env:
MONGO_TEST_URI: mongodb://127.0.0.1:27017
run: go test -v ./...
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ npm run dev
- [🚀 Installation Guide](./docs/INSTALLATION.md) - Complete installation instructions
- [⚙️ Configuration Guide](./docs/CONFIGURATION.md) - Environment variables and settings
- [🔧 Development Guide](./docs/DEVELOPMENT.md) - Set up development environment
- [🔐 Authentication](./docs/AUTHENTICATION.md) - Users, teams, permissions and API keys

### User Guides
- [📖 User Guide](./docs/USER_GUIDE.md) - How to use Tracker
Expand Down
76 changes: 72 additions & 4 deletions cmd/serv.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@ import (
"syscall"
"time"

authv1 "github.com/bananaops/tracker/generated/proto/auth/v1alpha1"
catalog "github.com/bananaops/tracker/generated/proto/catalog/v1alpha1"
event "github.com/bananaops/tracker/generated/proto/event/v1alpha1"
lock "github.com/bananaops/tracker/generated/proto/lock/v1alpha1"
"github.com/bananaops/tracker/internal/auth"
"github.com/bananaops/tracker/internal/auth/identity"
store "github.com/bananaops/tracker/internal/stores"
"github.com/bananaops/tracker/server"
"github.com/go-openapi/runtime/middleware"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
Expand All @@ -34,9 +38,60 @@ var serv = &cobra.Command{
Short: "Run tracker server",
Run: func(cmd *cobra.Command, args []string) {

ctx := context.TODO()

// Authentication: configuration, stores, bootstrap and principal resolver.
authCfg, err := auth.LoadConfig(os.LookupEnv)
if err != nil {
log.Fatalf("invalid authentication configuration: %v", err)
}
if authCfg.AnonymousDefaulted {
slog.Warn("AUTH_ANONYMOUS_PERMISSIONS is not set: anonymous callers keep every permission except access:manage. This default becomes empty in the next major release.")
}
userStore := store.NewAuthUserStore()
teamStore := store.NewAuthTeamStore()
keyStore := store.NewAuthAPIKeyStore()
settingsStore := store.NewAuthSettingsStore()

sessionSecret := authCfg.SessionSecret
if sessionSecret == nil {
sessionSecret, err = settingsStore.SessionSecret(ctx)
if err != nil {
log.Fatalf("cannot load session secret: %v", err)
}
slog.Info("AUTH_SESSION_SECRET is not set, using the secret persisted in MongoDB")
}
sessions, err := auth.NewSessionManager(sessionSecret, authCfg.SessionTTL)
if err != nil {
log.Fatalf("cannot create session manager: %v", err)
}

bootstrap, err := identity.Bootstrap(ctx, userStore, teamStore, authCfg.AdminPassword)
if err != nil {
log.Fatalf("authentication bootstrap failed: %v", err)
}
if bootstrap.AdminCreated {
if bootstrap.GeneratedPassword != "" {
slog.Warn("Initial admin account created with a generated password. Change it at first login.", "username", "admin", "password", bootstrap.GeneratedPassword)
} else {
slog.Info("Initial admin account created from AUTH_ADMIN_PASSWORD", "username", "admin")
}
}

resolver := &identity.Resolver{
Users: userStore,
Teams: teamStore,
Keys: keyStore,
Sessions: sessions,
AnonymousPermissions: authCfg.AnonymousPermissions,
}

// Set up gRPC server
grpcServerEndpoint := "localhost:8765"
grpcServer := grpc.NewServer()
grpcServer := grpc.NewServer(
grpc.ChainUnaryInterceptor(auth.UnaryInterceptor(resolver)),
grpc.ChainStreamInterceptor(auth.StreamInterceptor(resolver)),
)

// register reflection API https://github.com/grpc/grpc/blob/master/doc/server-reflection.md
reflection.Register(grpcServer)
Expand All @@ -53,12 +108,14 @@ var serv = &cobra.Command{
catalogs := server.NewCatalog()
catalog.RegisterCatalogServiceServer(grpcServer, catalogs)

// register auth service
authService := server.NewAuth(userStore, teamStore, keyStore, authCfg)
authv1.RegisterAuthServiceServer(grpcServer, authService)

// register health checK service
//healthCheckService := &server.HealthCheckService{}
//health.RegisterHealthServer(grpcServer, healthCheckService)

ctx := context.TODO()

// Initialiser les index MongoDB après la première connexion
db := server.GetDatabaseConnection()
if db != nil {
Expand All @@ -69,7 +126,7 @@ var serv = &cobra.Command{
mux := runtime.NewServeMux()

// Register generated routes to mux
err := event.RegisterEventServiceHandlerServer(ctx, mux, events)
err = event.RegisterEventServiceHandlerServer(ctx, mux, events)
if err != nil {
panic(err)
}
Expand All @@ -84,6 +141,14 @@ var serv = &cobra.Command{
panic(err)
}

err = authv1.RegisterAuthServiceHandlerServer(ctx, mux, authService)
if err != nil {
panic(err)
}

// Cookie based auth endpoints (login, logout, password change)
server.NewAuthHTTP(userStore, sessions, authCfg).Register(mux)

// Register Homer proxy endpoint
server.RegisterHomerHandler(mux, os.Getenv("HOMER_URL"))

Expand Down Expand Up @@ -228,6 +293,9 @@ var serv = &cobra.Command{
httpHandler = mux
}

// Resolve the principal of every HTTP request (SPA, API and custom handlers)
httpHandler = auth.HTTPMiddleware(resolver, authCfg)(httpHandler)

httpServer := &http.Server{
Addr: "0.0.0.0:8080",
ReadHeaderTimeout: 2 * time.Second, // Fix CWE-400 Potential Slowloris Attack because ReadHeaderTimeout is not configured in the http.Server
Expand Down
192 changes: 192 additions & 0 deletions docs/AUTHENTICATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
# Authentication and Access Control

Tracker authenticates every request and authorizes it against a small set of
permissions. Rights are granted to teams; users and API keys inherit the
rights of their teams.

## Permissions

| Permission | Grants |
|------------|--------|
| `event:read` | Read events, stats and changelogs |
| `event:write` | Create, update and delete events |
| `catalog:read` | Read the service catalog |
| `catalog:write` | Create, update and delete catalog entries |
| `lock:read` | List and read locks |
| `lock:write` | Create, update and release locks |
| `links:read` | Read custom links and Homer links |
| `links:write` | Manage custom links |
| `access:manage` | Manage users, teams and API keys |

`access:manage` is effectively full administrative control, not just user
management: a caller holding it can add itself to `Administrators` through
`UpdateUser`, or mint an API key on that team, and reach every permission
that way. Grant it as you would grant root.

Every gRPC method and REST route maps to exactly one permission. A method
missing from the mapping is refused. An anonymous caller lacking the
permission receives `401 Unauthorized` (gRPC `UNAUTHENTICATED`); an
authenticated caller lacking it receives `403 Forbidden`
(`PERMISSION_DENIED`).

## Anonymous access

`AUTH_ANONYMOUS_PERMISSIONS` lists the permissions granted without
credentials. When it is set, its value is used as is, even when empty. When
it is unset, the default is the read-only set
`event:read,catalog:read,lock:read,links:read` if `DEMO_MODE=true`, otherwise
every permission except `access:manage` (transitional default, so existing
installations keep working; the server logs a warning at startup, and the
default becomes empty in the next major release).

Set it to an empty value to require authentication everywhere:

```bash
AUTH_ANONYMOUS_PERMISSIONS=
```

## Initial administrator

On first start with an empty user collection, Tracker creates the built-in
team `Administrators` (every permission, every service) and a local user
`admin` in it. The password comes from `AUTH_ADMIN_PASSWORD`, or is generated
and printed once in the logs:

```
WARN Initial admin account created with a generated password. Change it at first login. username=admin password=...
```

The account is flagged `mustChangePassword`. Change it right away:

```bash
curl -c jar -X POST http://localhost:8080/api/v1alpha1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"<generated>"}'
curl -b jar -c jar -X POST http://localhost:8080/api/v1alpha1/auth/password \
-H 'Content-Type: application/json' \
-d '{"currentPassword":"<generated>","newPassword":"<new strong password>"}'
```

Passwords are hashed with Argon2id and must be 12 to 128 characters long.

## Sessions

Login sets an `HttpOnly`, `SameSite=Lax` cookie named `tracker_session`
valid for `AUTH_SESSION_TTL` (default 12 hours). The cookie is `Secure` when
`AUTH_PUBLIC_URL` starts with `https://` or `AUTH_COOKIE_SECURE=true`.
Changing a password, disabling a user or resetting its password invalidates
existing sessions. Five failed logins for the same username and IP within a
minute block further attempts for a minute. The client IP is the peer address
of the connection, unless `AUTH_TRUST_PROXY=true`, in which case it is the last
entry of `X-Forwarded-For`, the one appended by the reverse proxy. The earlier
entries are client controlled and must not be trusted.

A session token that no longer resolves, because it expired, was signed with
another secret, or its user was disabled or bumped, is refused with `401` when
it arrives in an `Authorization: Bearer` header. In the `tracker_session`
cookie it falls back to anonymous instead: the cookie 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.

Logout is stateless: it only clears the cookie, so a session token stolen
beforehand stays valid until its own expiry (`AUTH_SESSION_TTL`, 12 hours by
default). Changing the user's password, disabling the user or resetting its
password bumps the session version and is the only way to revoke a token
early.

| Endpoint | Description |
|----------|-------------|
| `POST /api/v1alpha1/auth/login` | Body `{"username","password"}`. `204` and cookie on success, `401` otherwise, `429` when rate limited. |
| `POST /api/v1alpha1/auth/logout` | Clears the cookie. |
| `POST /api/v1alpha1/auth/password` | Body `{"currentPassword","newPassword"}`. Requires a session. |
| `GET /api/v1alpha1/auth/me` | Identity, teams and effective permissions of the caller. Public. |
| `GET /api/v1alpha1/auth/config` | Login options and anonymous permissions. Public. |

### Browser cross-site requests

`SameSite=Lax` still lets a browser attach the session cookie to a top level
cross-site `GET` navigation, and the API keeps a write behind a `GET` binding
(`GET /api/v1alpha1/unlock/{id}`). A request is therefore treated as
cross-site when `Sec-Fetch-Site` is anything other than `same-origin`,
`same-site` or `none`, or, when that header is missing, when the `Origin`
header does not match `AUTH_PUBLIC_URL` (or the request scheme and `Host`
when `AUTH_PUBLIC_URL` is unset).

On such a request the session cookie is ignored and the caller is anonymous,
so it gets whatever `AUTH_ANONYMOUS_PERMISSIONS` grants and nothing more.
`POST /api/v1alpha1/auth/login` goes further and answers `403` before doing
any password work. API keys and `Authorization: Bearer` tokens are explicit
credentials, not ambient ones, and are never dropped. Requests carrying
neither header, which is every non browser client, are unaffected.

## Teams

A team carries a list of permissions, an optional list of catalog services
(empty means every service; per-service filtering is enforced in a later
release) and optional OIDC group names (used once OIDC lands). Users belong
to any number of teams and get the union of their rights. The built-in
`Administrators` team cannot be renamed, deleted or stripped of permissions.

| Endpoint | Permission |
|----------|------------|
| `GET/POST /api/v1alpha1/auth/teams` | `access:manage` |
| `PUT/DELETE /api/v1alpha1/auth/teams/{id}` | `access:manage` |
| `GET/POST /api/v1alpha1/auth/users` | `access:manage` |
| `PUT /api/v1alpha1/auth/users/{id}` | `access:manage` |

Deleting a team detaches its users and revokes its API keys. The last
enabled member of `Administrators` cannot be disabled or removed from the
team, and nobody can disable their own account.

## API keys

API keys are meant for automation (CI, the MCP server, scripts). A key
belongs to a team and inherits its rights, or is global (every permission)
when created without a team, which only members of `Administrators` may do.
A global API key is a full administrator credential.

| Endpoint | Permission |
|----------|------------|
| `GET /api/v1alpha1/auth/api-keys` | `access:manage` |
| `POST /api/v1alpha1/auth/api-keys` | `access:manage` |
| `DELETE /api/v1alpha1/auth/api-keys/{id}` | `access:manage` |

```bash
curl -b jar -X POST http://localhost:8080/api/v1alpha1/auth/api-keys \
-H 'Content-Type: application/json' \
-d '{"name":"ci","teamId":"<team id>","expiresAt":"2027-01-01T00:00:00Z"}'
```

The response contains the secret exactly once. Keys look like
`trk_<prefix>_<random>`; only a SHA-256 hash is stored. Present the key in
either header:

```
X-Api-Key: trk_...
Authorization: Bearer trk_...
```

For gRPC, send the same value in the `x-api-key` or `authorization`
metadata.

An API key that is malformed, unknown, revoked or expired is refused with
`401 Unauthorized` (gRPC `UNAUTHENTICATED`) on every route, public ones
included. It does **not** fall back to the anonymous principal: that would
hand a dead credential whatever `AUTH_ANONYMOUS_PERMISSIONS` grants, which
under the transitional default is wider than most keys carry. Revoking a key
limited to `event:read` would then silently promote it to `event:write`
instead of shutting it down.

Presenting no credential at all is unchanged: the caller is anonymous and gets
the anonymous permissions.

## Metrics

`tracker_auth_requests_total{principal,result}` counts authorization
decisions, with `principal` in `anonymous`, `user`, `apikey` and `result`
in `allowed`, `unauthenticated`, `denied`.

`tracker_auth_logins_total{method,result}` counts login attempts, with
`method` in `local` (`oidc` once it lands) and `result` in `success`,
`failure`, `rate_limited`. Malformed bodies, cross-site refusals and internal
errors are not login attempts and are not counted.
Loading
Loading