Skip to content

feat(auth): per-service team scope - #216

Open
TartanLeGrand wants to merge 13 commits into
BananaOps:mainfrom
TartanLeGrand:feat/auth-scope
Open

TartanLeGrand wants to merge 13 commits into
BananaOps:mainfrom
TartanLeGrand:feat/auth-scope

Conversation

@TartanLeGrand

@TartanLeGrand TartanLeGrand commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

What

Enforces the per-service team scope that scope_all / scope_services on teams already stored but never applied (PR 4 of the SSO series). A caller now only sees and writes the events, locks and catalog entries of the services of their teams. Backend and docs only: no proto, no generated code, no web change.

Commits:

  • feat(auth): add service scope helpers
  • feat(auth): validate team scope payloads
  • feat(auth): scope events by service
  • feat(auth): scope locks by service
  • fix(auth): do not write to out-of-scope events when unlocking
  • feat(auth): scope the catalog by service
  • test(auth): guard every RPC and HTTP route against unscoped access
  • test(auth): cover service scope end to end through the gateway
  • test(auth): assert stored state after denied scope requests
  • docs(auth): document the per-service team scope
  • fix(auth): count team scope service names in characters
  • docs(auth): warn that scope needs restricted anonymous access

Semantics

Full rules in docs/AUTHENTICATION.md, section "Service scope".

  • A user gets the union of the scopes of their teams, all as soon as one team is all; a team API key gets its team scope, re-read on every request. Global keys, anonymous callers and Administrators are all.
  • Lists, search, today, stats and monthly stats of events, locks and catalog (and version compliance) are filtered in the Mongo query.
  • Get, create, update, delete, unlock, changelog, Slack id, versions and dependencies on an object outside the scope answer 403 (PERMISSION_DENIED) naming the service; there is no 404 masking.
  • An update checks both the stored and the new service, so 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.
  • Version compliance treats a deliverable outside the scope as absent; a catalog entry in scope is returned whole, dependency names included.
  • A lock cannot be linked to an out-of-scope event; unlocking or completing an event still releases the in-scope lock without writing to the out-of-scope event.
  • Not scoped: custom links, Homer, /config.js, Swagger, AuthService (guarded by access:manage).
  • Team API: scopeAll: true with services is refused with 400, as is a list of blank services; names are trimmed and deduplicated (500 services of 128 characters max); Administrators cannot be restricted. GET /auth/me returns scopeAll / scopeServices.
  • Anonymous access caveat: the scope only isolates teams once anonymous access is restricted. Anonymous callers are all, and 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 not sending its credential. Set AUTH_ANONYMOUS_PERMISSIONS= (empty), or read-only permissions (anonymous reads then stay unscoped). Documented in docs/AUTHENTICATION.md.
  • Upgrade effect: a team already stored with a list of services becomes restricted when this version is deployed; teams created by the bootstrap or without services stay all.
  • Refusals are counted in the existing tracker_auth_requests_total{result="scope_denied"} metric.

Checks

  • go build ./..., go vet ./...: clean. gofmt -l: only the pre-existing internal/utils/utils_test.go.
  • go test with packages run serially against a MongoDB 7 container: all packages ok (server 75s). -race on ./server/, ./internal/stores/ and ./internal/auth/...: pass (counts of PASS lines include subtests).
  • golangci-lint run ./...: 0 issues. gosec -exclude=G103,G115 -exclude-generated ./...: 0 issues. govulncheck ./...: 0 vulnerabilities in called code.
  • Existing tests are untouched: git diff origin/main --stat -- '*_test.go' shows 10 new files, additions only.
  • A guard test lists every gRPC method and every HandlePath route and fails when one is neither scoped nor explicitly exempted.
  • Manual end-to-end against a real serv with a dedicated database: two teams scoped to service-a and service-b, two local users, a team-a API key. The user and the key see only service-a events, locks, catalog, stats and version compliance; get, update, delete and create on service-b answer 403; moving an event to service-b answers 403 and leaves it unchanged; a deployment start takes the service-a lock and success releases it; widening team-a to service-b applies to the same key on the next request and narrowing it back restores the 403; GET /auth/me reports the scope; the admin sees everything; scope_denied is counted for both user and API key principals and authz denied is logged.

Notes for reviewers

  • The store List, Search, count and aggregate functions now take an auth.Scope. Open PR feat(integrations): record deployments from GitLab and Flux webhooks #209 (deploy integrations) will need auth.ScopeAll() in its calls at rebase, and must read the stored lock in the UpdateLock RPC before calling its core. Its webhook routes must be added to the guard table (httpRouteSites).
  • Two new indexes: idx_lock_service and idx_catalog_name.
  • The HTTP half of the guard test only sees HandlePath calls under server/ and cmd/.
  • Users created by an admin carry mustChangePassword, but the backend does not enforce it (only the web UI redirects), so the manual run did not need a password change.

Follow-ups

  • Team scope selector in the web UI (after feat(web): login page, auth context and admin pages #201). Until then the team dialog of feat(web): login page, auth context and admin pages #201 shows the scope read-only and sends it back unchanged when a team is edited: create and change restricted scopes through the API.
  • On event creation, related_id can reference an out-of-scope event, which reveals that it exists and when it was created.
  • The pre-existing catalog indexes are created on the wrong collection.
  • The backend does not refuse RPCs for a session flagged mustChangePassword (only the web UI redirects): worth a separate hardening.
  • PR 5 (MCP server API key) will inherit the scope of its team.

Refs #196

authz.ScopeFromContext and authz.RequireService give RPC methods one place to read and enforce the service scope of the caller. store.ServiceFilter builds the matching MongoDB condition. A context without principal, an empty restricted scope and an empty service all fail closed. Scope denials are counted in tracker_auth_requests_total with result=scope_denied.

Refs BananaOps#196
A team scope that sets scope_all together with services, or whose services are all blank, is now rejected instead of silently becoming every service. Scopes are bounded to 500 services of 128 characters. No service still means every service, and the built-in Administrators team keeps an immutable unrestricted scope. Tests pin Me and team API keys returning the effective scope.

Refs BananaOps#196
Event lists, searches and statistics are filtered on attributes.service in MongoDB when the caller's scope is restricted. Reading, updating, deleting, commenting or linking a single event is refused with PermissionDenied when its service is outside the scope, and an update checks both the stored and the new service. Events without a service require an unrestricted scope. Nothing changes for callers whose scope is all.

Refs BananaOps#196
ListLocks is filtered on the lock service for restricted callers. CreateLock, GetLock, UpdateLock and UnLock are refused with PermissionDenied outside the scope, UpdateLock checks both the stored and the new service, and a lock cannot be linked to an event outside the scope. The locks taken and released by CreateEvent and UpdateEvent stay covered by the event checks. Adds the idx_lock_service index.

Refs BananaOps#196
UnLock of a lock in scope stays allowed, but the unlocked changelog entry is no longer written on a linked event outside the caller scope. Adds tests for the cross link, empty stored service, unknown event id and the non-unique idx_lock_service index.

Refs BananaOps#196
Catalog entries are filtered and checked on their name. An in-scope entry is returned as stored, including the names of the services it depends on. Version compliance is computed on the scoped list: only in-scope projects are reported and a deliverable outside the scope is treated as absent, so its versions do not leak. Adds the idx_catalog_name index on the collection the catalog store really uses.

Refs BananaOps#196
A table lists each RPC as filtered, checked or exempt with a reason, and must stay equal to the set of declared RPCs. Every non exempt RPC is called on real data with an empty scope: checked ones must answer PermissionDenied, filtered ones must return nothing. Custom HTTP routes are counted per file so a new one cannot ship without a stance.

Refs BananaOps#196
Users and a team API key of two teams go through the real mux and middleware: lists are filtered, unit operations outside the scope answer 403, Me returns the scope, a scope change applies to live sessions and keys, and anonymous, global keys and administrators keep seeing everything.

Refs BananaOps#196
Denied catalog, event and unlock requests now re-read MongoDB to prove nothing was written, and Me is checked for an explicit scopeAll false.

Refs BananaOps#196
Explains what a scope restricts, how lists and unit operations answer, the rules for objects without a service and for moving an object across scopes, what is not scoped, the team API validation and the upgrade note for teams that already list services.

Refs BananaOps#196
The 128 limit used the byte length while the docs and the error say characters, so multibyte names were rejected too early. Count runes instead. Also drop a process reference from a test comment.

Refs BananaOps#196
Document that the anonymous caller keeps scope all, so team scoping only isolates once AUTH_ANONYMOUS_PERMISSIONS is restricted. Correct the statement on teams without services and re-wrap a long line.

Refs BananaOps#196
The web team dialog (BananaOps#201) now sends back the scope it loaded, so
editing a restricted team from the UI no longer widens it.

Refs BananaOps#196

This branch has not been deployed

No deployments
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.

1 participant