Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
131 changes: 126 additions & 5 deletions docs/AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,10 +412,9 @@ AUTH_OIDC_SCOPES=openid profile email groups
## 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 (see
[Single Sign-On](#single-sign-on-openid-connect)). Users belong
to any number of teams and get the union of their rights. The built-in
(empty means every service, see [Service scope](#service-scope)) and optional
OIDC group names (see [Single Sign-On](#single-sign-on-openid-connect)). 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 |
Expand All @@ -429,6 +428,127 @@ 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.

## Service scope

> **Warning: the scope only isolates teams once anonymous access is
> restricted.** The anonymous caller always has scope `all`. While
> `AUTH_ANONYMOUS_PERMISSIONS` keeps its transitional default (every
> permission except `access:manage`), a restricted user or API key can read
> and write outside its scope by simply not sending its credential. Set
> `AUTH_ANONYMOUS_PERMISSIONS=` (empty) to require authentication
> everywhere, or list read-only permissions if anonymous reads are wanted
> (anonymous reads then stay unscoped). See
> [Anonymous access](#anonymous-access).

A team has a service scope: either `all` services, or a list of service
names. The scope restricts WHO sees WHAT; permissions are still required to
perform an operation.

- A user gets the union of the scopes of their teams, and `all` as soon as
one team is `all`. A user without any team sees nothing.
- A team API key gets the scope of its team. The scope is re-read on every
request, so a change applies immediately to sessions and keys alike.
- A global API key, the anonymous caller and the built-in `Administrators`
team are always `all`.
- Names are compared exactly, case included, with the catalog `name` and with
the `service` of events and locks. A service does not need to exist in the
catalog when it is added to a scope.

### What is scoped

| Data | Field | Filtered (only objects of the scope are returned) | Checked (`403` outside the scope) |
|------|-------|---------------------------------------------------|-----------------------------------|
| Events | `attributes.service` | list, search, today, stats, monthly stats | get, create, update, delete, changelog (read and add), Slack id |
| Locks | `service` | list | get, create, update, unlock |
| Catalog | `name` | list, version compliance | get, create or update, delete, versions, dependencies |

### Responses

- A list returns only the objects of the scope. A search or a statistic on a
service outside the scope returns an empty result.
- An operation on a single object outside the scope is refused with
`403 Forbidden` (gRPC `PERMISSION_DENIED`), and the error names the service.
Existence is not hidden (there is no `404` masking): service names are not
treated as secrets.
- An update checks both the stored and the new service: an object cannot be
moved into or out of the scope.
- An object without a service is only visible and writable with scope `all`.
A user who belongs to no team has an empty scope and sees and writes
nothing.

### Catalog

An in-scope catalog entry is returned whole, so it also shows the names of its
dependencies that are outside the scope (names only, nothing else about them).
Version compliance only lists the projects of the scope and treats a
deliverable outside the scope as absent. To track a shared deliverable, add it
to the scope of the team.

### Locks and events

- Creating a deployment event takes the lock of the same service.
- A lock cannot be linked to an event outside the scope. An unknown `event_id`
is accepted.
- Unlocking an in-scope lock that is linked to an out-of-scope event succeeds
but writes nothing to the changelog of that event.
- Completing an in-scope event releases its lock, even when the service of
that lock is outside the scope of the caller.

### Not scoped

Custom links and Homer links (they have no service field), `/config.js`,
Swagger, and the AuthService (identity administration, guarded by
`access:manage`).

### Known limits

- On event creation, `related_id` may reference an event outside the scope
(only its creation time is used to compute a duration). This reveals that the
event exists and when it was created.
- On locks, an unknown `event_id` and an out-of-scope `event_id` are
distinguishable (the second is refused with `403`).
- The scope selector of the web UI ships in a later release. Until then the
team dialog shows the scope read-only and keeps it unchanged when a team is
edited: create and change restricted scopes through the API.

### API

`POST /api/v1alpha1/auth/teams` and `PUT /api/v1alpha1/auth/teams/{id}` accept
`scopeAll` and `scopeServices`.

- No service means every service.
- `scopeAll: true` together with services is refused with `400`, and so is a
list of blank services.
- Names are trimmed and deduplicated, up to 500 services of 128 characters.
- The scope of `Administrators` cannot be restricted.

```bash
curl -b jar -X POST http://localhost:8080/api/v1alpha1/auth/teams \
-H 'Content-Type: application/json' \
-d '{"name":"payments","permissions":["event:read","event:write","lock:read","lock:write","catalog:read"],"scopeServices":["payments-api","payments-worker"]}'
```

`GET /api/v1alpha1/auth/me` reports the effective scope of the caller:

```json
{
"authenticated": true,
"kind": "user",
"username": "alice",
"permissions": ["event:read", "event:write"],
"scopeAll": false,
"scopeServices": ["payments-api", "payments-worker"]
}
```

### Upgrading

No action is required: existing teams keep their stored scope, `all` by
default. A team that was already created with a list of services (the field
existed but was not enforced) becomes restricted when you upgrade. Before
upgrading, list them with `GET /api/v1alpha1/auth/teams` and look for the teams
whose `scopeAll` is false.

## API keys

API keys are meant for automation (CI, the MCP server, scripts). A key
Expand Down Expand Up @@ -475,7 +595,8 @@ the anonymous permissions.

`tracker_auth_requests_total{principal,result}` counts authorization
decisions, with `principal` in `anonymous`, `user`, `apikey` and `result`
in `allowed`, `unauthenticated`, `denied`.
in `allowed`, `unauthenticated`, `denied`, `scope_denied`. A request refused
for its service scope was first counted `allowed` for its permission.

`tracker_auth_logins_total{method,result}` counts login attempts, with
`method` in `local`, `oidc` and `result` in `success`, `failure`,
Expand Down
2 changes: 2 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ BUY_ME_COFFEE_URL=https://www.buymeacoffee.com/yourname
| `AUTH_OIDC_TEAM_SYNC` | `true` | Synchronize teams from the groups claim at each login. |
| `AUTH_OIDC_BUTTON_LABEL` | `Single Sign-On` | Label of the login button (64 characters max). |

Teams can be restricted to a list of catalog services, see [Service scope](./AUTHENTICATION.md#service-scope). The scope is managed through the teams API, not through environment variables.

When `AUTH_ANONYMOUS_PERMISSIONS` 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, with a startup warning).

See [AUTHENTICATION.md](AUTHENTICATION.md) for permissions, teams and API keys, and [Single Sign-On](AUTHENTICATION.md#single-sign-on-openid-connect) for the OpenID Connect setup, redirect URI and identity provider recipes.
Expand Down
54 changes: 54 additions & 0 deletions internal/auth/authz/scope.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package authz

import (
"context"
"log/slog"

"github.com/bananaops/tracker/internal/auth"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// ResultScopeDenied is the result label of tracker_auth_requests_total for a
// request refused because its target service is outside the caller's scope.
const ResultScopeDenied = "scope_denied"

// ScopeFromContext returns the service scope of the current principal. A
// context without principal gets an empty restricted scope: it sees nothing.
func ScopeFromContext(ctx context.Context) auth.Scope {
p, ok := auth.FromContext(ctx)
if !ok {
return auth.ScopeOf()
}
return p.Scope
}

// RequireService refuses the request unless every given service is inside
// the scope of the current principal. Call it right after Authorize, with
// the service of the target object, and for an update with both the stored
// and the new service. An empty service is only allowed with an unrestricted
// scope. Service names are compared exactly, case included.
func RequireService(ctx context.Context, services ...string) error {
p, ok := auth.FromContext(ctx)
if !ok {
p = auth.Principal{Kind: auth.KindAnonymous, Username: "anonymous", Scope: auth.ScopeOf()}
}
if len(services) == 0 {
return denyScope(ctx, p, "")
}
for _, service := range services {
if !p.Scope.Allows(service) {
return denyScope(ctx, p, service)
}
}
return nil
}

func denyScope(ctx context.Context, p auth.Principal, service string) error {
authRequests.WithLabelValues(string(p.Kind), ResultScopeDenied).Inc()
slog.Warn("authz denied", "method", MethodFromContext(ctx), "principal", p.Username, "kind", p.Kind, "reason", "service outside scope", "service", service)
if service == "" {
return status.Error(codes.PermissionDenied, "objects without a service require an unrestricted scope")
}
return status.Errorf(codes.PermissionDenied, "service %q is outside your scope", service)
}
85 changes: 85 additions & 0 deletions internal/auth/authz/scope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package authz

import (
"context"
"testing"

"github.com/bananaops/tracker/internal/auth"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

func scopeTestContext() context.Context {
return grpc.NewContextWithServerTransportStream(context.Background(), fakeTransportStream{method: listEvents})
}

func withScope(scope auth.Scope) context.Context {
return auth.WithPrincipal(scopeTestContext(), auth.Principal{Kind: auth.KindUser, Username: "alice", Scope: scope})
}

func TestScopeFromContext(t *testing.T) {
empty := ScopeFromContext(scopeTestContext())
assert.False(t, empty.All)
assert.Empty(t, empty.ServiceList())

anon := ScopeFromContext(auth.WithPrincipal(scopeTestContext(), auth.Anonymous(nil)))
assert.True(t, anon.All)

scoped := ScopeFromContext(withScope(auth.ScopeOf("svc-a")))
assert.True(t, scoped.Allows("svc-a"))
assert.False(t, scoped.Allows("svc-b"))
}

func TestRequireService(t *testing.T) {
tests := []struct {
name string
ctx context.Context
services []string
denied bool
contains string
}{
{"all, service", withScope(auth.ScopeAll()), []string{"svc-a"}, false, ""},
{"all, empty service", withScope(auth.ScopeAll()), []string{""}, false, ""},
{"scoped, inside", withScope(auth.ScopeOf("svc-a")), []string{"svc-a"}, false, ""},
{"scoped, outside", withScope(auth.ScopeOf("svc-a")), []string{"svc-b"}, true, "svc-b"},
{"scoped, empty service", withScope(auth.ScopeOf("svc-a")), []string{""}, true, ""},
{"scoped, case sensitive", withScope(auth.ScopeOf("svc-a")), []string{"SVC-A"}, true, "SVC-A"},
{"scoped, one of two outside", withScope(auth.ScopeOf("svc-a")), []string{"svc-a", "svc-b"}, true, "svc-b"},
{"scoped, both inside", withScope(auth.ScopeOf("svc-a", "svc-b")), []string{"svc-a", "svc-b"}, false, ""},
{"empty scope", withScope(auth.ScopeOf()), []string{"svc-a"}, true, "svc-a"},
{"no principal", scopeTestContext(), []string{"svc-a"}, true, "svc-a"},
{"all, no argument", withScope(auth.ScopeAll()), nil, true, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := RequireService(tt.ctx, tt.services...)
if !tt.denied {
assert.NoError(t, err)
return
}
assert.Equal(t, codes.PermissionDenied, status.Code(err))
assert.Contains(t, err.Error(), tt.contains)
})
}
}

func TestRequireServiceCountsScopeDenials(t *testing.T) {
denied := func() float64 {
return testutil.ToFloat64(authRequests.WithLabelValues("user", ResultScopeDenied))
}
allowed := func() float64 {
return testutil.ToFloat64(authRequests.WithLabelValues("user", "allowed"))
}
ctx := withScope(auth.ScopeOf("svc-a"))

before, beforeAllowed := denied(), allowed()
assert.Error(t, RequireService(ctx, "svc-b"))
assert.Equal(t, before+1, denied())

assert.NoError(t, RequireService(ctx, "svc-a"))
assert.Equal(t, before+1, denied())
assert.Equal(t, beforeAllowed, allowed())
}
15 changes: 12 additions & 3 deletions internal/stores/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"

v1alpha1 "github.com/bananaops/tracker/generated/proto/catalog/v1alpha1"
"github.com/bananaops/tracker/internal/auth"

"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
Expand All @@ -20,9 +21,17 @@ func NewStoreCatalog(collection string) (c *CatalogStoreClient) {
}
}

// List takes label and field selectors, and returns the list of Catalogs that match those selectors.
func (c *CatalogStoreClient) List(ctx context.Context) (results []*v1alpha1.Catalog, err error) {
cursor, err := c.collection.Find(context.TODO(), bson.D{})
// NewStoreCatalogFromCollection wraps an existing collection (tests, custom wiring).
func NewStoreCatalogFromCollection(coll *mongo.Collection) *CatalogStoreClient {
return &CatalogStoreClient{collection: coll}
}

// catalogServiceField is the document field holding the service name of a catalog entry.
const catalogServiceField = "name"

// List returns the Catalogs within scope.
func (c *CatalogStoreClient) List(ctx context.Context, scope auth.Scope) (results []*v1alpha1.Catalog, err error) {
cursor, err := c.collection.Find(context.TODO(), scopedFilter(bson.D{}, scope, catalogServiceField))
if err != nil {
return nil, err
}
Expand Down
Loading
Loading