From 470639045b31b46167f8effb51e7be47c506d3a9 Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Thu, 3 Sep 2026 16:36:33 -0300 Subject: [PATCH] refactor(server): make route registration and declaration the same act A route made five claims about itself across three places. The shape, the unbounded claim and the anonymous claim went to the gateway wrapper; the permission and the API-key policy stayed behind as an echo middleware tail; anonymity was stated a second time in the authenticator's allowlist. And a declaration never learnt its own address, because the wrapper built it and something else mounted the result, so the route-table tests recovered a handler's identity by reflecting on its function pointer against a hand-written list of three. Mounting through the gateway is what closes that join. Echo deliberately hides a route's handler, so a declaration cannot be matched to its route after the fact; it can only be made where the route is mounted, which is also the only place the group-prefixed address is knowable. The ledgers become invariants over every route the router mounts. Nothing about request handling changes. The same guards run in the same order, returning the same responses. Three things the code cannot carry: Guards install in the order their options are written, because a RouteOption returns the middleware it enforces and mount appends them as it applies them. That is what preserves the old tail's order, where RequiresTenant ran before RequiresPermission and BlockAPIKey before both. The shape's build takes the Declaration by value, so a serving handler closes over a copy. Mount writes the address onto its own local after echo has already made the route servable, and a handler that shared that memory would be read while it was being written. The per-router table never evicts. Keying on router identity is what keeps an edition-gated route registered by one test from reading as a stale claim on another's, and the cost is bounded by routers created per process: one in production, a few dozen in a test binary. Anonymous on a route still using the legacy adapter has no runtime effect, since that adapter does not resolve an actor. It is there as the claim the allowlist is held against, and eleven routes gained it to state what the allowlist already granted them. The SSH, reverse-dial, web-terminal and pprof routes are mounted onto the same router by the server that composes it, after NewRouter returns, so this seam cannot check them. They are named with their reasons, and the one invariant available over them is asserted: none is this router's. The cloud repo no longer compiles against this, because the four shape constructors changed their return type. It adopts the same registration in its own stacked change. Fixes: shellhub-io/shellhub#7029 --- server/api/pkg/gateway/guard.go | 38 +++ server/api/pkg/gateway/mount.go | 118 +++++++ server/api/pkg/gateway/mount_test.go | 195 ++++++++++++ server/api/pkg/gateway/route.go | 242 ++++++++------ server/api/pkg/gateway/route_test.go | 16 +- server/api/pkg/gateway/utils.go | 6 +- server/api/routes/guard_test.go | 126 ++++++++ server/api/routes/middleware/authorize.go | 28 +- server/api/routes/route_table_test.go | 368 ++++++++++++++++------ server/api/routes/routes.go | 234 +++++++------- 10 files changed, 1047 insertions(+), 324 deletions(-) create mode 100644 server/api/pkg/gateway/guard.go create mode 100644 server/api/pkg/gateway/mount.go create mode 100644 server/api/pkg/gateway/mount_test.go create mode 100644 server/api/routes/guard_test.go diff --git a/server/api/pkg/gateway/guard.go b/server/api/pkg/gateway/guard.go new file mode 100644 index 00000000000..f5e38d4c63a --- /dev/null +++ b/server/api/pkg/gateway/guard.go @@ -0,0 +1,38 @@ +package gateway + +import ( + "net/http" + + "github.com/labstack/echo/v5" + "github.com/shellhub-io/shellhub/pkg/api/authorizer" +) + +// RequiresPermission refuses the request with 403 unless the role the request authenticated with +// holds permission. It answers 403 rather than 401 because the caller is known and simply not +// allowed; a request carrying no credential at all never reaches it. +// +// It lives here rather than beside the other route middleware because [Requires] installs it, and +// the route middleware package imports this one. +func RequiresPermission(permission authorizer.Permission) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + if ctx, ok := From(c); !ok || !ctx.Role().HasPermission(permission) { + return c.NoContent(http.StatusForbidden) + } + + return next(c) + } + } +} + +// BlockAPIKey refuses with 403 a request that authenticated with an API key. It reads the header +// rather than the resolved identity because a key is refused whether or not it was honoured. +func BlockAPIKey(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + if key := c.Request().Header.Get("X-API-Key"); key != "" { + return c.NoContent(http.StatusForbidden) + } + + return next(c) + } +} diff --git a/server/api/pkg/gateway/mount.go b/server/api/pkg/gateway/mount.go new file mode 100644 index 00000000000..f6dad6fecca --- /dev/null +++ b/server/api/pkg/gateway/mount.go @@ -0,0 +1,118 @@ +package gateway + +import ( + "net/http" + "sort" + "sync" + + "github.com/labstack/echo/v5" +) + +// Target is the part of echo's routing API mounting needs. Both *echo.Echo and *echo.Group +// satisfy it, and both answer with the address the route ended up at — the group prefix already +// applied — which is the address the router reports and the authenticator matches on. +type Target interface { + Add(method, path string, handler echo.HandlerFunc, middleware ...echo.MiddlewareFunc) echo.RouteInfo +} + +// Mounter mounts routes onto one target and declares them against one router. Mounting and +// declaring are the same act: a declaration cannot be recorded for a route nobody mounted, and a +// route mounted through a mounter cannot escape the declaration. +// +// The router is carried separately from the target because a group does not name the router it +// was carved from, and it is the router a claim belongs to. +type Mounter struct { + router *echo.Echo + target Target +} + +// MountOn returns a mounter adding routes to target and declaring them against router. Pass +// router as target to mount on its root; pass a group of it to mount under that group's prefix. +func MountOn(router *echo.Echo, target Target) *Mounter { + return &Mounter{router: router, target: target} +} + +// GET mounts route at path answering GET, declares it against the mounter's router, and returns +// the address it was mounted at — which is the address the declaration then carries. Each option's +// guard wraps the handler in the order the option is written, so the guard written first is the +// one that answers first. +// +// [POST], [PUT], [PATCH] and [DELETE] do the same for their methods. +func GET(m *Mounter, path string, route Route, options ...RouteOption) echo.RouteInfo { + return mount(m, http.MethodGet, path, route, options) +} + +// POST mounts route at path, answering POST. See [GET] for what mounting declares. +func POST(m *Mounter, path string, route Route, options ...RouteOption) echo.RouteInfo { + return mount(m, http.MethodPost, path, route, options) +} + +// PUT mounts route at path, answering PUT. See [GET] for what mounting declares. +func PUT(m *Mounter, path string, route Route, options ...RouteOption) echo.RouteInfo { + return mount(m, http.MethodPut, path, route, options) +} + +// PATCH mounts route at path, answering PATCH. See [GET] for what mounting declares. +func PATCH(m *Mounter, path string, route Route, options ...RouteOption) echo.RouteInfo { + return mount(m, http.MethodPatch, path, route, options) +} + +// DELETE mounts route at path, answering DELETE. See [GET] for what mounting declares. +func DELETE(m *Mounter, path string, route Route, options ...RouteOption) echo.RouteInfo { + return mount(m, http.MethodDelete, path, route, options) +} + +func mount(m *Mounter, method, path string, route Route, options []RouteOption) echo.RouteInfo { + declaration := route.declaration + + guards := make([]echo.MiddlewareFunc, 0, len(options)) + + for _, option := range options { + if guard := option(&declaration); guard != nil { + guards = append(guards, guard) + } + } + + info := m.target.Add(method, path, route.build(declaration), guards...) + + declaration.Method, declaration.Path = info.Method, info.Path + + declare(m.router, declaration) + + return info +} + +var tables = struct { + sync.Mutex + byRouter map[*echo.Echo][]Declaration +}{byRouter: make(map[*echo.Echo][]Declaration)} + +func declare(router *echo.Echo, declaration Declaration) { + tables.Lock() + defer tables.Unlock() + + tables.byRouter[router] = append(tables.byRouter[router], declaration) +} + +// Declarations returns what every route mounted on router claims, ordered by address. The table +// belongs to the router rather than to the process, so an invariant over it holds regardless of +// which other routers — under which other editions — a neighbouring test built. +// +// Repeats are kept: two declarations sharing an address is how a shadowed route shows up, and +// collapsing them here would hide it. +func Declarations(router *echo.Echo) []Declaration { + tables.Lock() + defer tables.Unlock() + + all := append([]Declaration(nil), tables.byRouter[router]...) + + sort.Slice(all, func(i, j int) bool { + if all[i].Path != all[j].Path { + return all[i].Path < all[j].Path + } + + return all[i].Method < all[j].Method + }) + + return all +} diff --git a/server/api/pkg/gateway/mount_test.go b/server/api/pkg/gateway/mount_test.go new file mode 100644 index 00000000000..6ce991f5fad --- /dev/null +++ b/server/api/pkg/gateway/mount_test.go @@ -0,0 +1,195 @@ +package gateway_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + "github.com/shellhub-io/shellhub/pkg/api/authorizer" + "github.com/shellhub-io/shellhub/pkg/api/scope" + "github.com/shellhub-io/shellhub/server/api/pkg/gateway" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func rootOf(router *echo.Echo) *gateway.Mounter { + return gateway.MountOn(router, router) +} + +func okRoute() gateway.Route { + return gateway.None(func(_ context.Context, _ scope.Scope, _ gateway.Actor, _ *probeRequest) error { + return nil + }) +} + +// TestMountingCompletesTheDeclarationWithItsAddress is the join this package exists to close: +// echo hides a route's handler, so a claim can only be matched to a route if the claim was made +// where the route was mounted. The group prefix is part of that address, and it is the prefixed +// form the router reports and the authenticator matches on. +func TestMountingCompletesTheDeclarationWithItsAddress(t *testing.T) { + e := probeRouter(t, true) + + gateway.GET(gateway.MountOn(e, e.Group("/api")), "/devices", okRoute()) + gateway.POST(rootOf(e), "/root", okRoute()) + + addresses := make([]string, 0) + for _, declaration := range gateway.Declarations(e) { + addresses = append(addresses, declaration.Address()) + } + + assert.Equal(t, []string{"GET /api/devices", "POST /root"}, addresses) + + registered := make([]string, 0) + for _, route := range e.Router().Routes() { + registered = append(registered, route.Method+" "+route.Path) + } + + assert.ElementsMatch(t, addresses, registered) +} + +// TestDeclarationsBelongToTheRouterThatMountedThem keeps an invariant over one route table from +// depending on which other routers a neighbouring test built — an edition-gated route registered +// by one of them would otherwise read as a stale claim on this one. +func TestDeclarationsBelongToTheRouterThatMountedThem(t *testing.T) { + first, second := probeRouter(t, true), probeRouter(t, true) + + gateway.GET(rootOf(first), "/first", okRoute()) + gateway.GET(rootOf(second), "/second", okRoute()) + + require.Len(t, gateway.Declarations(first), 1) + require.Len(t, gateway.Declarations(second), 1) + + assert.Equal(t, "GET /first", gateway.Declarations(first)[0].Address()) + assert.Equal(t, "GET /second", gateway.Declarations(second)[0].Address()) +} + +// TestRequiresDeclaresThePermitItEnforces pins what makes the declaration evidence rather than +// documentation: the option that records the permission is the option that installs its guard, so +// the two cannot drift. +func TestRequiresDeclaresThePermitItEnforces(t *testing.T) { + cases := []struct { + description string + role string + expectedStatus int + }{ + { + description: "refuses a role without the permission", + role: "observer", + expectedStatus: http.StatusForbidden, + }, + { + description: "admits a role holding it", + role: "owner", + expectedStatus: http.StatusOK, + }, + } + + for _, tc := range cases { + t.Run(tc.description, func(t *testing.T) { + e := probeRouter(t, true) + gateway.GET(rootOf(e), "/probe", okRoute(), gateway.Requires(authorizer.DeviceRemove)) + + declarations := gateway.Declarations(e) + require.Len(t, declarations, 1) + assert.True(t, declarations[0].RequiresPermission) + assert.Equal(t, authorizer.DeviceRemove, declarations[0].Permission) + + assert.Equal(t, tc.expectedStatus, probe(t, e, map[string]string{ + "X-Tenant-ID": probeTenant, + "X-ID": "user-id", + "X-Role": tc.role, + })) + }) + } +} + +// TestNoAPIKeyDeclaresTheBlockItEnforces covers the other declarative guard: a route closed to API +// keys says so, and refuses one. +func TestNoAPIKeyDeclaresTheBlockItEnforces(t *testing.T) { + e := probeRouter(t, true) + gateway.GET(rootOf(e), "/probe", okRoute(), gateway.NoAPIKey()) + + declarations := gateway.Declarations(e) + require.Len(t, declarations, 1) + assert.True(t, declarations[0].BlocksAPIKey) + + assert.Equal(t, http.StatusForbidden, probe(t, e, map[string]string{ + "X-Tenant-ID": probeTenant, + "X-API-Key": "a-key", + })) + + assert.Equal(t, http.StatusOK, probe(t, e, map[string]string{ + "X-Tenant-ID": probeTenant, + "X-ID": "user-id", + })) +} + +// TestGuardsRunInTheOrderTheyAreWritten is the risk in moving two guards out of the middleware +// tail: the tail ran in registration order, and the options have to keep doing so — the guard that +// answers first is what a caller sees. +func TestGuardsRunInTheOrderTheyAreWritten(t *testing.T) { + order := make([]string, 0) + + mark := func(name string) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + order = append(order, name) + + return next(c) + } + } + } + + e := probeRouter(t, true) + gateway.GET(rootOf(e), "/probe", okRoute(), + gateway.Guard(mark("first")), + gateway.NoAPIKey(), + gateway.Guard(mark("second")), + gateway.Requires(authorizer.DeviceRemove), + gateway.Guard(mark("third"))) + + require.Equal(t, http.StatusOK, probe(t, e, map[string]string{ + "X-Tenant-ID": probeTenant, + "X-ID": "user-id", + "X-Role": "owner", + })) + + assert.Equal(t, []string{"first", "second", "third"}, order) +} + +// TestGuardDeclaresNothing states the boundary the change stops at: a guard that is not a claim — +// the tenant check, the legacy authorize middleware — runs, and the declaration does not pretend +// to describe it. +func TestGuardDeclaresNothing(t *testing.T) { + refuse := func(_ echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + return c.NoContent(http.StatusTeapot) + } + } + + e := probeRouter(t, true) + gateway.GET(rootOf(e), "/probe", okRoute(), gateway.Guard(refuse)) + + declarations := gateway.Declarations(e) + require.Len(t, declarations, 1) + assert.False(t, declarations[0].RequiresPermission) + assert.False(t, declarations[0].BlocksAPIKey) + + assert.Equal(t, http.StatusTeapot, probe(t, e, map[string]string{"X-Tenant-ID": probeTenant, "X-ID": "user-id"})) +} + +func probe(t *testing.T, e *echo.Echo, headers map[string]string) int { + t.Helper() + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/probe", nil) + for name, value := range headers { + req.Header.Set(name, value) + } + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + return rec.Code +} diff --git a/server/api/pkg/gateway/route.go b/server/api/pkg/gateway/route.go index af1296a44fd..4911e734d88 100644 --- a/server/api/pkg/gateway/route.go +++ b/server/api/pkg/gateway/route.go @@ -5,11 +5,10 @@ import ( "net/http" "reflect" "runtime" - "sort" "strconv" - "sync" "github.com/labstack/echo/v5" + "github.com/shellhub-io/shellhub/pkg/api/authorizer" "github.com/shellhub-io/shellhub/pkg/api/query" "github.com/shellhub-io/shellhub/pkg/api/scope" routes "github.com/shellhub-io/shellhub/server/api/routes/errors" @@ -29,6 +28,9 @@ const ( ShapeList Shape = "list" // ShapeNone answers with 200 and no body. ShapeNone Shape = "none" + // ShapeLegacy is a handler that still writes its own response through the gateway [Context]. + // It declares its address and its guards like any other route; only its body has yet to move. + ShapeLegacy Shape = "legacy" ) // OneHandler answers with a single value. It is a function of its inputs: it does not know that @@ -43,61 +45,90 @@ type ListHandler[T, R any] func(ctx context.Context, sc scope.Scope, actor Actor // NoneHandler answers with success alone. type NoneHandler[T any] func(ctx context.Context, sc scope.Scope, actor Actor, req *T) error -// One registers handler as a route answering with a JSON body. -func One[T, R any](handler OneHandler[T, R], options ...RouteOption) echo.HandlerFunc { - declaration := declare(handler, ShapeOne, options) - - return func(c *echo.Context) error { - in, err := prepare[T](c, declaration) - if err != nil { - return err - } - - res, err := handler(in.ctx, in.scope, in.actor, in.req) - if err != nil { - return err - } +// Route is a handler and the claim its registration is about to make. It is not yet a route the +// router serves: it has no address until [GET] and its siblings mount it, which is what completes +// the declaration. +type Route struct { + declaration Declaration + build func(Declaration) echo.HandlerFunc +} - return c.JSON(http.StatusOK, res) +// One answers with a JSON body. +func One[T, R any](handler OneHandler[T, R]) Route { + return Route{ + declaration: Declaration{Handler: handlerName(handler), Shape: ShapeOne}, + build: func(declaration Declaration) echo.HandlerFunc { + return func(c *echo.Context) error { + in, err := prepare[T](c, declaration) + if err != nil { + return err + } + + res, err := handler(in.ctx, in.scope, in.actor, in.req) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, res) + } + }, } } -// List registers handler as a route answering with a JSON body and the total-count header. -func List[T, R any](handler ListHandler[T, R], options ...RouteOption) echo.HandlerFunc { - declaration := declare(handler, ShapeList, options) - - return func(c *echo.Context) error { - in, err := prepare[T](c, declaration) - if err != nil { - return err - } - - res, count, err := handler(in.ctx, in.scope, in.actor, in.req) - if err != nil { - return err - } - - c.Response().Header().Set(totalCountHeader, strconv.Itoa(count)) - - return c.JSON(http.StatusOK, res) +// List answers with a JSON body and the total-count header. +func List[T, R any](handler ListHandler[T, R]) Route { + return Route{ + declaration: Declaration{Handler: handlerName(handler), Shape: ShapeList}, + build: func(declaration Declaration) echo.HandlerFunc { + return func(c *echo.Context) error { + in, err := prepare[T](c, declaration) + if err != nil { + return err + } + + res, count, err := handler(in.ctx, in.scope, in.actor, in.req) + if err != nil { + return err + } + + c.Response().Header().Set(totalCountHeader, strconv.Itoa(count)) + + return c.JSON(http.StatusOK, res) + } + }, } } -// None registers handler as a route answering with 200 and no body. -func None[T any](handler NoneHandler[T], options ...RouteOption) echo.HandlerFunc { - declaration := declare(handler, ShapeNone, options) - - return func(c *echo.Context) error { - in, err := prepare[T](c, declaration) - if err != nil { - return err - } - - if err := handler(in.ctx, in.scope, in.actor, in.req); err != nil { - return err - } +// None answers with 200 and no body. +func None[T any](handler NoneHandler[T]) Route { + return Route{ + declaration: Declaration{Handler: handlerName(handler), Shape: ShapeNone}, + build: func(declaration Declaration) echo.HandlerFunc { + return func(c *echo.Context) error { + in, err := prepare[T](c, declaration) + if err != nil { + return err + } + + if err := handler(in.ctx, in.scope, in.actor, in.req); err != nil { + return err + } + + return c.NoContent(http.StatusOK) + } + }, + } +} - return c.NoContent(http.StatusOK) +// Handler adapts a handler that still writes its own response, so that it can be mounted and +// declared like any other route. It fails the request when no gateway [Context] was installed, +// which means the route was registered outside the gateway's group. +func Handler(next func(*Context) error) Route { + return Route{ + declaration: Declaration{Handler: handlerName(next), Shape: ShapeLegacy}, + build: func(_ Declaration) echo.HandlerFunc { + return adapt(next) + }, } } @@ -146,36 +177,94 @@ func prepare[T any](c *echo.Context, declaration Declaration) (inputs[T], error) return inputs[T]{ctx: gCtx.Ctx(), scope: sc, actor: actor, req: req}, nil } -// RouteOption declares an exception to the two rules every route follows: it is bounded to a -// namespace, and it is performed by an actor. -type RouteOption func(*Declaration) +// RouteOption states one claim a route's registration makes, and returns the guard that enforces +// it — or nil when the claim is enforced by the wrapper rather than by a middleware. Options are +// applied in the order they are written, and their guards run in that same order. +type RouteOption func(*Declaration) echo.MiddlewareFunc // Unbounded declares that the route deliberately reads across namespaces, and records why that is // safe. The reason is a required argument, so breadth cannot arrive by omission — only by someone // typing why. func Unbounded(reason string) RouteOption { - return func(d *Declaration) { + return func(d *Declaration) echo.MiddlewareFunc { d.Unbounded, d.UnboundedReason = true, reason + + return nil } } // Anonymous declares that the route deliberately carries no actor, and records why that is safe. // It is independent of [Unbounded]: a device authenticating with its own token is bounded to a // namespace and still carries no actor. +// +// The claim frees the handler from needing an actor; it does not open the route. What lets the +// request past the credential check is the authenticator's allowlist, and the route table's tests +// are what hold the two to the same answer. func Anonymous(reason string) RouteOption { - return func(d *Declaration) { + return func(d *Declaration) echo.MiddlewareFunc { d.Anonymous, d.AnonymousReason = true, reason + + return nil + } +} + +// Requires declares the permission the route demands of the caller's role, and installs the guard +// that enforces it. Declaring and enforcing are the same act here, so the declaration is evidence +// of what the route does rather than a description of it. +func Requires(permission authorizer.Permission) RouteOption { + return func(d *Declaration) echo.MiddlewareFunc { + d.Permission, d.RequiresPermission = permission, true + + return RequiresPermission(permission) + } +} + +// NoAPIKey declares that the route is closed to API keys, and installs the guard that refuses +// one. It is for the routes that must be performed by a person: an API key authenticates a +// namespace, and names nobody to hold responsible for the act. +func NoAPIKey() RouteOption { + return func(d *Declaration) echo.MiddlewareFunc { + d.BlocksAPIKey = true + + return BlockAPIKey } } -// Declaration is what one route registration claims about itself: the shape it answers with, and -// any exception it takes to the default rules. +// Guard installs a middleware the declaration says nothing about. It is what the guards that are +// not claims — the tenant check, the legacy authorize middleware — are written with, and it runs +// in the position it is written in, among the guards the other options install. +func Guard(middleware echo.MiddlewareFunc) RouteOption { + return func(_ *Declaration) echo.MiddlewareFunc { + return middleware + } +} + +// Declaration is what one route registration claims about itself: where it is mounted, the shape +// it answers with, the authority it demands, and any exception it takes to the default rules. +// +// It is complete only once the route is mounted, because the address is the mounting's answer and +// not the registration's. type Declaration struct { // Handler is the fully qualified name of the wrapped function, which is what ties a claim back // to the code it is about. Handler string Shape Shape + // Method and Path are the address the router mounted the route at, with any group prefix + // applied. It is the same string the router reports and the authenticator matches on. + Method string + Path string + + // Permission is what the route demands of the caller's role. The zero value is a real + // permission, so RequiresPermission is what tells it apart from a route demanding none. + Permission authorizer.Permission + RequiresPermission bool + + // BlocksAPIKey reports whether the route refuses a request authenticated by an API key. The + // refusal is about the credential and not the authority: a key carrying a role that holds the + // route's permission is refused all the same. + BlocksAPIKey bool + Unbounded bool UnboundedReason string @@ -183,6 +272,12 @@ type Declaration struct { AnonymousReason string } +// Address returns the route's method and path as the router and the authenticator both spell it, +// which is the key a claim is joined to a route by. +func (d Declaration) Address() string { + return d.Method + " " + d.Path +} + func (d Declaration) resolveScope(c *Context) (scope.Scope, error) { if d.Unbounded { return scope.NewUnbounded(d.UnboundedReason), nil @@ -203,41 +298,6 @@ func (d Declaration) resolveActor(c *Context) (Actor, error) { return Actor{}, routes.NewErrUnauthorized(nil) } -var declarations = struct { - sync.Mutex - set map[Declaration]struct{} -}{set: make(map[Declaration]struct{})} - -func declare(handler any, shape Shape, options []RouteOption) Declaration { - d := Declaration{Handler: handlerName(handler), Shape: shape} - for _, option := range options { - option(&d) - } - - declarations.Lock() - defer declarations.Unlock() - - declarations.set[d] = struct{}{} - - return d -} - -// Declarations returns every claim the route tables built in this process have made, ordered by -// handler name. -func Declarations() []Declaration { - declarations.Lock() - defer declarations.Unlock() - - all := make([]Declaration, 0, len(declarations.set)) - for d := range declarations.set { - all = append(all, d) - } - - sort.Slice(all, func(i, j int) bool { return all[i].Handler < all[j].Handler }) - - return all -} - func handlerName(handler any) string { value := reflect.ValueOf(handler) if value.Kind() != reflect.Func { diff --git a/server/api/pkg/gateway/route_test.go b/server/api/pkg/gateway/route_test.go index 3fed24258a6..5aa1f89db98 100644 --- a/server/api/pkg/gateway/route_test.go +++ b/server/api/pkg/gateway/route_test.go @@ -216,7 +216,7 @@ func TestWrapperCeremony(t *testing.T) { call := new(probeCall) e := probeRouter(t, tc.withGatewayContext) - e.GET("/probe", gateway.List(probeHandler(call, []string{"item"}, 1, nil), tc.options...)) + gateway.GET(rootOf(e), "/probe", gateway.List(probeHandler(call, []string{"item"}, 1, nil)), tc.options...) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, tc.target, nil) for name, value := range tc.headers { @@ -266,7 +266,7 @@ func TestListWritesTheTotalCountAfterTheErrorCheck(t *testing.T) { call := new(probeCall) e := probeRouter(t, true) - e.GET("/probe", gateway.List(probeHandler(call, []string{"item"}, tc.count, tc.err))) + gateway.GET(rootOf(e), "/probe", gateway.List(probeHandler(call, []string{"item"}, tc.count, tc.err))) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/probe", nil) req.Header.Set("X-Tenant-ID", probeTenant) @@ -292,7 +292,7 @@ func TestWrapperNormalizesNothingForARequestCarryingNeither(t *testing.T) { var got *plainRequest e := probeRouter(t, true) - e.GET("/probe", gateway.One(func(_ context.Context, _ scope.Scope, _ gateway.Actor, req *plainRequest) (string, error) { + gateway.GET(rootOf(e), "/probe", gateway.One(func(_ context.Context, _ scope.Scope, _ gateway.Actor, req *plainRequest) (string, error) { got = req return "ok", nil @@ -312,7 +312,7 @@ func TestWrapperNormalizesNothingForARequestCarryingNeither(t *testing.T) { func TestOneEncodesTheHandlerResult(t *testing.T) { e := probeRouter(t, true) - e.GET("/probe", gateway.One(func(_ context.Context, _ scope.Scope, _ gateway.Actor, _ *probeRequest) (map[string]string, error) { + gateway.GET(rootOf(e), "/probe", gateway.One(func(_ context.Context, _ scope.Scope, _ gateway.Actor, _ *probeRequest) (map[string]string, error) { return map[string]string{"name": "value"}, nil })) @@ -330,7 +330,7 @@ func TestOneEncodesTheHandlerResult(t *testing.T) { func TestNoneAnswersWithoutABody(t *testing.T) { e := probeRouter(t, true) - e.GET("/probe", gateway.None(func(_ context.Context, _ scope.Scope, _ gateway.Actor, _ *probeRequest) error { + gateway.GET(rootOf(e), "/probe", gateway.None(func(_ context.Context, _ scope.Scope, _ gateway.Actor, _ *probeRequest) error { return nil })) @@ -351,13 +351,13 @@ func TestDeclarationsRecordEveryClaim(t *testing.T) { const unboundedReason = "the declared probe reads every namespace" e := probeRouter(t, true) - e.GET("/declared", gateway.List(probeHandler(new(probeCall), nil, 0, nil), + gateway.GET(rootOf(e), "/declared", gateway.List(probeHandler(new(probeCall), nil, 0, nil)), gateway.Unbounded(unboundedReason), - gateway.Anonymous("the declared probe establishes the actor"))) + gateway.Anonymous("the declared probe establishes the actor")) var found bool - for _, declaration := range gateway.Declarations() { + for _, declaration := range gateway.Declarations(e) { if declaration.UnboundedReason != unboundedReason { continue } diff --git a/server/api/pkg/gateway/utils.go b/server/api/pkg/gateway/utils.go index 90b0f26ff31..4fabad88279 100644 --- a/server/api/pkg/gateway/utils.go +++ b/server/api/pkg/gateway/utils.go @@ -6,9 +6,7 @@ import ( "github.com/labstack/echo/v5" ) -// Handler adapts a gateway handler to echo's, failing the request when no gateway [Context] -// was installed — which means the route was registered outside the gateway's group. -func Handler(next func(*Context) error) echo.HandlerFunc { +func adapt(next func(*Context) error) echo.HandlerFunc { return func(c *echo.Context) error { gCtx, ok := From(c) if !ok { @@ -29,7 +27,7 @@ func stash(c *echo.Context, gCtx *Context) { func Middleware(m echo.MiddlewareFunc) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c *echo.Context) error { - return Handler(func(c *Context) error { + return adapt(func(c *Context) error { return m(next)(c.Context) })(c) } diff --git a/server/api/routes/guard_test.go b/server/api/routes/guard_test.go new file mode 100644 index 00000000000..21460764cf5 --- /dev/null +++ b/server/api/routes/guard_test.go @@ -0,0 +1,126 @@ +package routes + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/shellhub-io/shellhub/pkg/api/authorizer" + "github.com/shellhub-io/shellhub/pkg/models" + "github.com/shellhub-io/shellhub/server/api/services/mocks" + "github.com/stretchr/testify/assert" + gomock "github.com/stretchr/testify/mock" +) + +const guardTenant = "00000000-0000-4000-0000-000000000000" + +// TestGuardsRefuseWhatTheyRefusedBefore drives one route per guard through the built router. The +// route table now states two of these guards on the line that mounts the route instead of trailing +// them as middleware, and the order they run in moved with them — so what a caller sees is the +// thing worth pinning, not where the guard is written. +func TestGuardsRefuseWhatTheyRefusedBefore(t *testing.T) { + cases := []struct { + description string + method string + target string + headers map[string]string + mocks func(*mocks.MockService) + expectedStatus int + }{ + { + description: "the permission guard refuses a role that does not hold it", + method: http.MethodDelete, + target: "/api/devices/1234", + headers: map[string]string{ + "X-ID": "000000000000000000000000", + "X-Tenant-ID": guardTenant, + "X-Role": authorizer.RoleObserver.String(), + }, + expectedStatus: http.StatusForbidden, + }, + { + description: "the permission guard admits a role that holds it", + method: http.MethodDelete, + target: "/api/devices/1234", + headers: map[string]string{ + "X-ID": "000000000000000000000000", + "X-Tenant-ID": guardTenant, + "X-Role": authorizer.RoleOwner.String(), + }, + mocks: func(service *mocks.MockService) { + service.On("DeleteDevice", gomock.Anything, models.UID("1234"), guardTenant).Return(nil).Once() + }, + expectedStatus: http.StatusOK, + }, + { + description: "the API-key block refuses a request that authenticated with a key", + method: http.MethodGet, + target: "/api/namespaces/api-key", + headers: map[string]string{ + "X-Tenant-ID": guardTenant, + "X-Role": authorizer.RoleOwner.String(), + "X-API-Key": "a-key", + }, + expectedStatus: http.StatusForbidden, + }, + { + description: "the API-key block admits a request carrying no key", + method: http.MethodGet, + target: "/api/namespaces/api-key", + headers: map[string]string{ + "X-ID": "000000000000000000000000", + "X-Tenant-ID": guardTenant, + "X-Role": authorizer.RoleOwner.String(), + }, + mocks: func(service *mocks.MockService) { + service. + On("ListAPIKeys", gomock.Anything, gomock.AnythingOfType("*requests.ListAPIKey")). + Return([]models.APIKey{}, 0, nil). + Once() + }, + expectedStatus: http.StatusOK, + }, + { + description: "the authorize guard refuses an identity carrying no namespace", + method: http.MethodGet, + target: "/api/devices", + headers: map[string]string{ + "X-ID": "000000000000000000000000", + "X-Role": authorizer.RoleOwner.String(), + }, + expectedStatus: http.StatusForbidden, + }, + { + description: "the tenant guard refuses a namespace the caller is not scoped to", + method: http.MethodGet, + target: "/api/namespaces/" + guardTenant, + headers: map[string]string{ + "X-ID": "000000000000000000000000", + "X-Tenant-ID": "11111111-1111-4111-1111-111111111111", + "X-Role": authorizer.RoleOwner.String(), + }, + expectedStatus: http.StatusForbidden, + }, + } + + for _, tc := range cases { + t.Run(tc.description, func(t *testing.T) { + service := mocks.NewMockService(t) + if tc.mocks != nil { + tc.mocks(service) + } + + req := httptest.NewRequestWithContext(t.Context(), tc.method, tc.target, nil) + req.Header.Set("Content-Type", "application/json") + + for name, value := range tc.headers { + req.Header.Set(name, value) + } + + rec := httptest.NewRecorder() + NewRouter(service).ServeHTTP(rec, req) + + assert.Equal(t, tc.expectedStatus, rec.Code, rec.Body.String()) + }) + } +} diff --git a/server/api/routes/middleware/authorize.go b/server/api/routes/middleware/authorize.go index 5023f5e45c1..47c1b674f32 100644 --- a/server/api/routes/middleware/authorize.go +++ b/server/api/routes/middleware/authorize.go @@ -31,30 +31,18 @@ func Authorize(next echo.HandlerFunc) echo.HandlerFunc { } } -// BlockAPIKey blocks request using API keys to continue. +// BlockAPIKey blocks request using API keys to continue. It is [gateway.BlockAPIKey] under the +// name its callers outside this repository still use; a route in this one states the claim with +// [gateway.NoAPIKey] on the line that mounts it. func BlockAPIKey(next echo.HandlerFunc) echo.HandlerFunc { - return func(c *echo.Context) error { - if key := c.Request().Header.Get("X-API-Key"); key != "" { - return c.NoContent(http.StatusForbidden) - } - - return next(c) - } + return gateway.BlockAPIKey(next) } -// RequiresPermission reports whether the client has the specified permission. -// If not, it returns an [http.StatusForbidden] response. Otherwise, it executes -// the next handler. +// RequiresPermission reports whether the client has the specified permission. It is +// [gateway.RequiresPermission] under the name its callers outside this repository still use; a +// route in this one states the claim with [gateway.Requires] on the line that mounts it. func RequiresPermission(permission authorizer.Permission) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c *echo.Context) error { - if ctx, ok := gateway.From(c); !ok || !ctx.Role().HasPermission(permission) { - return c.NoContent(http.StatusForbidden) - } - - return next(c) - } - } + return gateway.RequiresPermission(permission) } // RequiresTenant enforces that the caller's tenant scope matches the tenant diff --git a/server/api/routes/route_table_test.go b/server/api/routes/route_table_test.go index 6260dafbd4a..1c5f0361722 100644 --- a/server/api/routes/route_table_test.go +++ b/server/api/routes/route_table_test.go @@ -1,147 +1,337 @@ package routes import ( + "net/http" + "sort" "strings" "testing" + "github.com/labstack/echo/v5" "github.com/shellhub-io/shellhub/server/api/pkg/gateway" + routesmiddleware "github.com/shellhub-io/shellhub/server/api/routes/middleware" + sshhttp "github.com/shellhub-io/shellhub/server/ssh/http" + sshweb "github.com/shellhub-io/shellhub/server/ssh/web" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +var gatewayExemptRoutes = map[string]string{ + "GET " + InternalMetricsURL: "the Prometheus registry answers a scrape, which carries no identity and reads no namespace", + echo.RouteAny + " /mcp": "the MCP transport dispatches to the routes below it, which is where the claims are", + echo.RouteAny + " /mcp/*": "the MCP transport dispatches to the routes below it, which is where the claims are", +} + +var composedServerPaths = map[string]string{ + sshhttp.HandleConnectionV1Path: "the agent's tunnel is a websocket the SSH sidecar owns, and it answers to a device token rather than to a namespace member", + sshhttp.HandleConnectionV2Path: "the agent's tunnel is a websocket the SSH sidecar owns, and it answers to a device token rather than to a namespace member", + sshhttp.HandleRevdialPath: "the reverse dial hands the connection to the dialer, and never reaches a handler that could bind a request", + sshhttp.HandleSSHClosePath: "the SSH package mounts it on the router root, so it carries an /api address without entering that group's chain: the authenticator installed at the root is what guards it", + sshweb.WebSessionRoute: "the web terminal's handoff is served by a raw http.Handler, outside echo's binder and validator", + sshweb.WebsocketSSHBridgeRoute: "the web terminal's websocket is served by a raw http.Handler, outside echo's binder and validator", + "/debug/pprof": "the profiler group is registered only in development, by the server rather than by the router", +} + +func addresses(router *echo.Echo) map[string]struct{} { + registered := make(map[string]struct{}) + for _, route := range router.Router().Routes() { + registered[route.Method+" "+route.Path] = struct{}{} + } + + return registered +} + func unstatedClaims(declarations []gateway.Declaration) []string { unstated := make([]string, 0) for _, declaration := range declarations { if declaration.Unbounded && strings.TrimSpace(declaration.UnboundedReason) == "" { - unstated = append(unstated, declaration.Handler+" reads across namespaces and states no reason") + unstated = append(unstated, declaration.Address()+" reads across namespaces and states no reason") } if declaration.Anonymous && strings.TrimSpace(declaration.AnonymousReason) == "" { - unstated = append(unstated, declaration.Handler+" requires no actor and states no reason") + unstated = append(unstated, declaration.Address()+" requires no actor and states no reason") } } return unstated } -// TestRouteTableStatesEveryClaim reads the claims the route table made while it was built. A route -// that reads across namespaces, or that needs no actor, has to say why — otherwise breadth and -// anonymity arrive by omission, which is what the two claims exist to prevent. -func TestRouteTableStatesEveryClaim(t *testing.T) { - authenticatedRouter(t) +func undeclaredRoutes(registered map[string]struct{}, declarations []gateway.Declaration, exempt map[string]string) []string { + declared := make(map[string]struct{}, len(declarations)) + for _, declaration := range declarations { + declared[declaration.Address()] = struct{}{} + } - declarations := gateway.Declarations() - require.NotEmpty(t, declarations, "the route table registered no wrapped route") + undeclared := make([]string, 0) - assert.Empty(t, unstatedClaims(declarations)) + for address := range registered { + if _, ok := declared[address]; ok { + continue + } + + if reason, ok := exempt[address]; ok && strings.TrimSpace(reason) != "" { + continue + } + + undeclared = append(undeclared, address+" is mounted but claims nothing") + } + + sort.Strings(undeclared) + + return undeclared } -// TestUnstatedClaimsRefusesAnEmptyReason proves the check above bites, rather than passing because -// it looks at nothing. -func TestUnstatedClaimsRefusesAnEmptyReason(t *testing.T) { - unstated := unstatedClaims([]gateway.Declaration{ - {Handler: "silent", Unbounded: true, Anonymous: true}, - {Handler: "stated", Unbounded: true, UnboundedReason: "because"}, - }) +func unmountedDeclarations(registered map[string]struct{}, declarations []gateway.Declaration) []string { + unmounted := make([]string, 0) - require.Len(t, unstated, 2) - for _, complaint := range unstated { - assert.Contains(t, complaint, "silent") + for _, declaration := range declarations { + if _, ok := registered[declaration.Address()]; !ok { + unmounted = append(unmounted, declaration.Address()+" is declared but no such route is mounted") + } } + + return unmounted } -var wrapperExemptRoutes = []string{ - "GET /api/install", - "POST /api/login", - "POST /api/auth/user", - "POST /api/tags", - "POST /api/namespaces/:tenant/tags", +func shadowedRoutes(declarations []gateway.Declaration) []string { + seen := make(map[string]struct{}, len(declarations)) + shadowed := make([]string, 0) + + for _, declaration := range declarations { + if _, ok := seen[declaration.Address()]; ok { + shadowed = append(shadowed, declaration.Address()+" is mounted more than once") + + continue + } + + seen[declaration.Address()] = struct{}{} + } + + return shadowed } -// TestWrapperExemptRoutesAreRegistered catches a stale member: an exempt route that no longer -// exists, or was renamed, leaves the set claiming an exemption for nothing. -func TestWrapperExemptRoutesAreRegistered(t *testing.T) { - router, _, _ := authenticatedRouter(t) +func anonymityMismatches(declarations []gateway.Declaration, allowlist []string) []string { + allowed := make(map[string]struct{}, len(allowlist)) + for _, entry := range allowlist { + allowed[entry] = struct{}{} + } - registered := make(map[string]struct{}) - for _, route := range router.Router().Routes() { - registered[route.Method+" "+route.Path] = struct{}{} + mismatches := make([]string, 0) + + for _, declaration := range declarations { + _, byMethod := allowed[declaration.Address()] + _, byAnyMethod := allowed[routesmiddleware.AnyMethod+" "+declaration.Path] + + if declaration.Anonymous == (byMethod || byAnyMethod) { + continue + } + + if declaration.Anonymous { + mismatches = append(mismatches, declaration.Address()+" claims no actor but the authenticator demands a credential") + + continue + } + + mismatches = append(mismatches, declaration.Address()+" is reachable without a credential but claims an actor") } - for _, exempt := range wrapperExemptRoutes { - assert.Contains(t, registered, exempt, "the exempt set names %q but no such route is registered", exempt) + return mismatches +} + +func misplacedComposedRoutes(router *echo.Echo, composed map[string]string) []string { + misplaced := make([]string, 0) + + for path, reason := range composed { + if strings.TrimSpace(reason) == "" { + misplaced = append(misplaced, path+" is named as the composed server's and states no reason") + } + + for _, route := range router.Router().Routes() { + if route.Path == path || strings.HasPrefix(route.Path, path+"/") { + misplaced = append(misplaced, path+" is named as the composed server's but the router mounts it") + + break + } + } } + + sort.Strings(misplaced) + + return misplaced } -var convertedRoutes = []struct { - handler string - shape gateway.Shape - route string -}{ - {handler: "EvaluateHealth", shape: gateway.ShapeNone, route: "GET /api" + HealthCheckURL}, - {handler: "GetDevice", shape: gateway.ShapeOne, route: "GET /api" + GetDeviceURL}, - {handler: "GetDeviceList", shape: gateway.ShapeList, route: "GET /api" + GetDeviceListURL}, +func staleExemptions(registered map[string]struct{}, exempt map[string]string) []string { + stale := make([]string, 0) + + for address, reason := range exempt { + if _, ok := registered[address]; !ok { + stale = append(stale, address+" is exempt but no such route is mounted") + } + + if strings.TrimSpace(reason) == "" { + stale = append(stale, address+" is exempt and states no reason") + } + } + + sort.Strings(stale) + + return stale } -func methodName(qualified string) string { - return strings.TrimSuffix(qualified[strings.LastIndex(qualified, ".")+1:], "-fm") +// TestRouteTableHoldsItsClaims reads the whole route table of a fully built router against the +// claims its registrations made. Every check is one predicate over that table, and each has a +// companion below feeding it a known-bad input, so a passing run means the predicate looked. +func TestRouteTableHoldsItsClaims(t *testing.T) { + router, authn, _ := authenticatedRouter(t) + + declarations := gateway.Declarations(router) + require.NotEmpty(t, declarations, "the route table registered no declaration") + + registered := addresses(router) + + t.Run("every claim that takes an exception states why", func(t *testing.T) { + assert.Empty(t, unstatedClaims(declarations)) + }) + + t.Run("every mounted route is declared or exempt", func(t *testing.T) { + assert.Empty(t, undeclaredRoutes(registered, declarations, gatewayExemptRoutes)) + }) + + t.Run("every declaration names a mounted route", func(t *testing.T) { + assert.Empty(t, unmountedDeclarations(registered, declarations)) + }) + + t.Run("no address is mounted twice", func(t *testing.T) { + assert.Empty(t, shadowedRoutes(declarations)) + }) + + t.Run("the anonymity claims and the allowlist agree", func(t *testing.T) { + assert.Empty(t, anonymityMismatches(declarations, authn.AnonymousRoutes())) + }) + + t.Run("every exemption names a mounted route and states why", func(t *testing.T) { + assert.Empty(t, staleExemptions(registered, gatewayExemptRoutes)) + }) + + t.Run("the composed server's routes are named and are not this router's", func(t *testing.T) { + assert.Empty(t, misplacedComposedRoutes(router, composedServerPaths)) + }) } -// TestAnonymousClaimsMatchTheAllowlist joins the two places a route's anonymity is stated. The -// gateway claim frees the handler from needing an actor; the authenticator's allowlist is what lets -// the request past the credential check. Nothing but this holds them to the same answer, and a -// route carrying one without the other is either unreachable or reachable without a credential. -func TestAnonymousClaimsMatchTheAllowlist(t *testing.T) { - _, authn, _ := authenticatedRouter(t) +// TestUnstatedClaimsRefusesAnEmptyReason proves the check above bites, rather than passing because +// it looks at nothing. +func TestUnstatedClaimsRefusesAnEmptyReason(t *testing.T) { + unstated := unstatedClaims([]gateway.Declaration{ + {Method: "GET", Path: "/silent", Unbounded: true, Anonymous: true}, + {Method: "GET", Path: "/stated", Unbounded: true, UnboundedReason: "because"}, + }) - allowed := make(map[string]struct{}) - for _, route := range authn.AnonymousRoutes() { - allowed[route] = struct{}{} + require.Len(t, unstated, 2) + for _, complaint := range unstated { + assert.Contains(t, complaint, "/silent") } +} - claimed := make(map[string]bool) - for _, declaration := range gateway.Declarations() { - claimed[methodName(declaration.Handler)] = declaration.Anonymous +// TestUndeclaredRoutesCatchesARouteThatClaimsNothing drives the case the invariant exists for: a +// route mounted around the gateway, which no audit would otherwise reach. +func TestUndeclaredRoutesCatchesARouteThatClaimsNothing(t *testing.T) { + registered := map[string]struct{}{ + "GET /declared": {}, + "GET /exempt": {}, + "GET /smuggled": {}, + "GET /unreasoned": {}, } - for _, tc := range convertedRoutes { - t.Run(tc.handler, func(t *testing.T) { - _, inAllowlist := allowed[tc.route] + undeclared := undeclaredRoutes( + registered, + []gateway.Declaration{{Method: "GET", Path: "/declared"}}, + map[string]string{"GET /exempt": "a reason", "GET /unreasoned": " "}, + ) - assert.Equal(t, claimed[tc.handler], inAllowlist, - "%s declares Anonymous=%v but the authenticator's allowlist says %v", - tc.handler, claimed[tc.handler], inAllowlist) - }) - } + assert.Equal(t, []string{ + "GET /smuggled is mounted but claims nothing", + "GET /unreasoned is mounted but claims nothing", + }, undeclared) } -// TestConvertedRoutesDeclareTheirShape pins the three routes this change converted: each answers -// with the shape it was registered under, and each is mounted at the address it claims. -// -// The second half is what keeps the declaration honest. Echo does not expose a route's handler, so -// a declaration cannot be matched to its route in general — but for a named handler at a known -// address, asserting both is enough to rule out a claim recorded by a wrapper nothing mounted. -func TestConvertedRoutesDeclareTheirShape(t *testing.T) { - router, _, _ := authenticatedRouter(t) +// TestUnmountedDeclarationsCatchesAStaleClaim is the other direction: a claim recorded for a route +// that was renamed or removed is believed by every reader until something checks it. +func TestUnmountedDeclarationsCatchesAStaleClaim(t *testing.T) { + unmounted := unmountedDeclarations( + map[string]struct{}{"GET /mounted": {}}, + []gateway.Declaration{{Method: "GET", Path: "/mounted"}, {Method: "GET", Path: "/gone"}}, + ) - registered := make(map[string]struct{}) - for _, route := range router.Router().Routes() { - registered[route.Method+" "+route.Path] = struct{}{} - } + require.Len(t, unmounted, 1) + assert.Contains(t, unmounted[0], "/gone") +} - shapes := make(map[string]gateway.Shape) - for _, declaration := range gateway.Declarations() { - shapes[methodName(declaration.Handler)] = declaration.Shape - } +// TestShadowedRoutesCatchesADoubleMount matters because echo overwrites silently: the second +// registration wins, and the first route's guards simply stop running. +func TestShadowedRoutesCatchesADoubleMount(t *testing.T) { + shadowed := shadowedRoutes([]gateway.Declaration{ + {Method: "GET", Path: "/devices"}, + {Method: "POST", Path: "/devices"}, + {Method: "GET", Path: "/devices"}, + }) - for _, tc := range convertedRoutes { - t.Run(tc.handler, func(tt *testing.T) { - declared, found := shapes[tc.handler] + require.Len(t, shadowed, 1) + assert.Contains(t, shadowed[0], "GET /devices") +} - require.True(tt, found, "%s is not registered through a gateway shape", tc.handler) - assert.Equal(tt, tc.shape, declared, "%s answers with the wrong shape", tc.handler) - assert.Contains(tt, registered, tc.route, "%s declares a shape but is not mounted", tc.handler) - }) - } +// TestAnonymityMismatchesCatchesBothDirections joins the two places a route's anonymity is stated. +// The gateway claim frees the handler from needing an actor; the authenticator's allowlist is what +// lets the request past the credential check. A route carrying one without the other is either +// unreachable or reachable without a credential. +func TestAnonymityMismatchesCatchesBothDirections(t *testing.T) { + mismatches := anonymityMismatches( + []gateway.Declaration{ + {Method: "GET", Path: "/agreed", Anonymous: true}, + {Method: "GET", Path: "/guarded"}, + {Method: "GET", Path: "/unreachable", Anonymous: true}, + {Method: "GET", Path: "/open"}, + }, + []string{"GET /agreed", "GET /open"}, + ) + + require.Len(t, mismatches, 2) + assert.Contains(t, mismatches[0], "/unreachable") + assert.Contains(t, mismatches[1], "/open") +} + +// TestMisplacedComposedRoutesCatchesOneThatMoved keeps the naming honest in the only direction +// this seam can check. A route named as the composed server's that turns up on a router [NewRouter] +// built is no longer outside the coverage invariant's reach, and belongs in the exempt set where +// its exemption is checked both ways. +func TestMisplacedComposedRoutesCatchesOneThatMoved(t *testing.T) { + router := echo.New() + router.GET("/moved", func(c *echo.Context) error { return c.NoContent(http.StatusOK) }) + router.GET("/nested/deep", func(c *echo.Context) error { return c.NoContent(http.StatusOK) }) + + misplaced := misplacedComposedRoutes(router, map[string]string{ + "/elsewhere": "a reason", + "/moved": "a reason", + "/nested": "a reason", + "/silent": "", + }) + + assert.Equal(t, []string{ + "/moved is named as the composed server's but the router mounts it", + "/nested is named as the composed server's but the router mounts it", + "/silent is named as the composed server's and states no reason", + }, misplaced) +} + +// TestStaleExemptionsCatchesAnExemptionNothingMounts keeps the exempt set from outliving the +// routes it excuses, and from excusing one without saying why. +func TestStaleExemptionsCatchesAnExemptionNothingMounts(t *testing.T) { + stale := staleExemptions( + map[string]struct{}{"GET /kept": {}, "GET /silent": {}}, + map[string]string{"GET /kept": "a reason", "GET /gone": "a reason", "GET /silent": ""}, + ) + + assert.Equal(t, []string{ + "GET /gone is exempt but no such route is mounted", + "GET /silent is exempt and states no reason", + }, stale) } diff --git a/server/api/routes/routes.go b/server/api/routes/routes.go index 7d63553659f..46d9c6c96bb 100644 --- a/server/api/routes/routes.go +++ b/server/api/routes/routes.go @@ -117,129 +117,139 @@ func NewRouter(service services.Service, opts ...Option) *echo.Echo { } } - publicAPI := router.Group("/api") - publicAPI.GET(HealthCheckURL, - gateway.None(handler.EvaluateHealth, - gateway.Unbounded("the health check reports on the instance, which belongs to no namespace"), - gateway.Anonymous("the health check is what a load balancer asks before any credential exists"))) - - publicAPI.GET(AuthLocalUserURLV2, gateway.Handler(handler.CreateUserToken)) // TODO: method POST - publicAPI.GET(AuthUserTokenPublicURL, gateway.Handler(handler.CreateUserToken), routesmiddleware.BlockAPIKey) // TODO: method POST - publicAPI.POST(AuthDeviceURL, gateway.Handler(handler.AuthDevice)) - publicAPI.POST(AuthDeviceURLV2, gateway.Handler(handler.AuthDevice)) - publicAPI.POST(EnrollmentCallbackURL, gateway.Handler(handler.EnrollmentCallback)) - publicAPI.POST(AuthLocalUserURL, gateway.Handler(handler.AuthLocalUser)) - publicAPI.POST(AuthLocalUserURLV2, gateway.Handler(handler.AuthLocalUser)) - publicAPI.POST(AuthPublicKeyURL, gateway.Handler(handler.AuthPublicKey)) - - publicAPI.POST(CreateAPIKeyURL, gateway.Handler(handler.CreateAPIKey), routesmiddleware.BlockAPIKey, routesmiddleware.RequiresPermission(authorizer.APIKeyCreate)) - publicAPI.GET(ListAPIKeysURL, gateway.Handler(handler.ListAPIKeys), routesmiddleware.BlockAPIKey) - publicAPI.PATCH(UpdateAPIKeyURL, gateway.Handler(handler.UpdateAPIKey), routesmiddleware.BlockAPIKey, routesmiddleware.RequiresPermission(authorizer.APIKeyUpdate)) - publicAPI.DELETE(DeleteAPIKeyURL, gateway.Handler(handler.DeleteAPIKey), routesmiddleware.BlockAPIKey, routesmiddleware.RequiresPermission(authorizer.APIKeyDelete)) - - publicAPI.POST(CreateInstallKeyURL, gateway.Handler(handler.CreateInstallKey), routesmiddleware.BlockAPIKey, routesmiddleware.RequiresPermission(authorizer.InstallKeyCreate)) - publicAPI.GET(ListInstallKeysURL, gateway.Handler(handler.ListInstallKeys), routesmiddleware.BlockAPIKey, routesmiddleware.RequiresPermission(authorizer.InstallKeyList)) - publicAPI.PATCH(UpdateInstallKeyURL, gateway.Handler(handler.UpdateInstallKey), routesmiddleware.BlockAPIKey, routesmiddleware.RequiresPermission(authorizer.InstallKeyUpdate)) - publicAPI.GET(RevealInstallKeyURL, gateway.Handler(handler.RevealInstallKey), routesmiddleware.BlockAPIKey, routesmiddleware.RequiresPermission(authorizer.InstallKeyReveal)) - publicAPI.GET(HistoryInstallKeyURL, gateway.Handler(handler.HistoryInstallKey), routesmiddleware.BlockAPIKey, routesmiddleware.RequiresPermission(authorizer.InstallKeyList)) - - publicAPI.PATCH(URLUpdateUser, gateway.Handler(handler.UpdateUser), routesmiddleware.BlockAPIKey) - publicAPI.PATCH(URLDeprecatedUpdateUser, gateway.Handler(handler.UpdateUser), routesmiddleware.BlockAPIKey) // WARN: DEPRECATED. - publicAPI.PATCH(URLDeprecatedUpdateUserPassword, gateway.Handler(handler.UpdateUserPassword), routesmiddleware.BlockAPIKey) // WARN: DEPRECATED. - - publicAPI.POST(RegisterUserURL, gateway.Handler(handler.RegisterUser)) - publicAPI.GET(URLResolveInvitation, gateway.Handler(handler.ResolveInvitation)) - publicAPI.POST(URLGenerateInvitationLink, gateway.Handler(handler.GenerateInvitationLink), routesmiddleware.BlockAPIKey, routesmiddleware.RequiresPermission(authorizer.NamespaceAddMember)) - publicAPI.PATCH(URLAcceptInvite, gateway.Handler(handler.AcceptInvite), routesmiddleware.BlockAPIKey) - publicAPI.GET(URLUserMembershipInvitationList, gateway.Handler(handler.GetUserMembershipInvitationList)) - publicAPI.GET(URLNamespaceMembershipInvitationList, gateway.Handler(handler.GetNamespaceMembershipInvitationList), routesmiddleware.RequiresPermission(authorizer.NamespaceEditMember)) - publicAPI.DELETE(URLCancelMembershipInvitation, gateway.Handler(handler.CancelMembershipInvitation), routesmiddleware.RequiresPermission(authorizer.NamespaceRemoveMember)) - - publicAPI.GET(GetDeviceListURL, routesmiddleware.Authorize(gateway.List(handler.GetDeviceList))) - publicAPI.GET(GetDeviceURL, routesmiddleware.Authorize(gateway.One(handler.GetDevice))) - publicAPI.GET(ResolveDeviceURL, routesmiddleware.Authorize(gateway.Handler(handler.ResolveDevice))) - publicAPI.PUT(UpdateDevice, gateway.Handler(handler.UpdateDevice), routesmiddleware.RequiresPermission(authorizer.DeviceUpdate)) - publicAPI.PATCH(RenameDeviceURL, gateway.Handler(handler.RenameDevice), routesmiddleware.RequiresPermission(authorizer.DeviceRename)) - publicAPI.PATCH(UpdateDeviceStatusURL, gateway.Handler(handler.UpdateDeviceStatus), routesmiddleware.RequiresPermission(authorizer.DeviceAccept)) // TODO: DeviceWrite - - publicAPI.POST(CreateDeviceLoginCodeURL, gateway.Handler(handler.CreateDeviceLoginCode)) - publicAPI.GET(GetDeviceAuthStatusURL, gateway.Handler(handler.GetDeviceAuthStatus)) - publicAPI.GET(ResolveDeviceLoginCodeURL, gateway.Handler(handler.ResolveDeviceLoginCode), routesmiddleware.BlockAPIKey) - - publicAPI.POST(CreateDevicePairingURL, gateway.Handler(handler.CreateDevicePairing)) - publicAPI.GET(GetDevicePairingStatusURL, gateway.Handler(handler.GetDevicePairingStatus)) - publicAPI.POST(AcceptDevicePairingURL, gateway.Handler(handler.AcceptDevicePairing), routesmiddleware.BlockAPIKey) - publicAPI.POST(PrepareDevicePairingURL, gateway.Handler(handler.PrepareDevicePairing), routesmiddleware.BlockAPIKey, routesmiddleware.RequiresPermission(authorizer.DeviceAccept)) - - publicAPI.GET(GetSSHApprovalURL, gateway.Handler(handler.GetSSHApproval), routesmiddleware.BlockAPIKey) - publicAPI.POST(ConfirmSSHApprovalURL, gateway.Handler(handler.ConfirmSSHApproval), routesmiddleware.BlockAPIKey) - publicAPI.POST(RejectSSHApprovalURL, gateway.Handler(handler.RejectSSHApproval), routesmiddleware.BlockAPIKey) - publicAPI.DELETE(DeleteDeviceURL, gateway.Handler(handler.DeleteDevice), routesmiddleware.RequiresPermission(authorizer.DeviceRemove)) - publicAPI.PUT(SetDeviceCustomFieldURL, gateway.Handler(handler.SetDeviceCustomField), routesmiddleware.RequiresPermission(authorizer.DeviceCustomFieldUpdate)) - publicAPI.DELETE(DeleteDeviceCustomFieldURL, gateway.Handler(handler.DeleteDeviceCustomField), routesmiddleware.RequiresPermission(authorizer.DeviceCustomFieldUpdate)) - - publicAPI.GET(URLGetTags, gateway.Handler(handler.GetTags)) - publicAPI.POST(URLCreateTag, gateway.Handler(handler.CreateTag), routesmiddleware.RequiresPermission(authorizer.TagCreate)) - publicAPI.PATCH(URLUpdateTag, gateway.Handler(handler.UpdateTag), routesmiddleware.RequiresPermission(authorizer.TagUpdate)) - publicAPI.DELETE(URLDeleteTag, gateway.Handler(handler.DeleteTag), routesmiddleware.RequiresPermission(authorizer.TagDelete)) - publicAPI.POST(URLPushTagToDevice, gateway.Handler(handler.PushTagToDevice), routesmiddleware.RequiresPermission(authorizer.TagCreate)) - publicAPI.DELETE(URLPullTagFromDevice, gateway.Handler(handler.PullTagFromDevice), routesmiddleware.RequiresPermission(authorizer.TagDelete)) - - publicAPI.GET(URLOldGetTags, gateway.Handler(handler.GetTags)) - publicAPI.POST(URLOldCreateTag, gateway.Handler(handler.CreateTag), routesmiddleware.RequiresPermission(authorizer.TagCreate)) - publicAPI.PATCH(URLOldUpdateTag, gateway.Handler(handler.UpdateTag), routesmiddleware.RequiresPermission(authorizer.TagUpdate)) - publicAPI.DELETE(URLOldDeleteTag, gateway.Handler(handler.DeleteTag), routesmiddleware.RequiresPermission(authorizer.TagDelete)) - publicAPI.POST(URLOldPushTagToDevice, gateway.Handler(handler.PushTagToDevice), routesmiddleware.RequiresPermission(authorizer.TagCreate)) - publicAPI.DELETE(URLOldPullTagFromDevice, gateway.Handler(handler.PullTagFromDevice), routesmiddleware.RequiresPermission(authorizer.TagDelete)) - - publicAPI.GET(GetSessionsURL, routesmiddleware.Authorize(gateway.Handler(handler.GetSessionList))) - publicAPI.GET(GetSessionURL, routesmiddleware.Authorize(gateway.Handler(handler.GetSession))) - - publicAPI.GET(GetStatsURL, routesmiddleware.Authorize(gateway.Handler(handler.GetStats))) - publicAPI.GET(GetSystemInfoURL, gateway.Handler(handler.GetSystemInfo)) - publicAPI.GET(GetSystemDownloadInstallScriptURL, gateway.Handler(handler.GetSystemDownloadInstallScript)) - - publicAPI.POST(CreatePublicKeyURL, gateway.Handler(handler.CreatePublicKey), routesmiddleware.RequiresPermission(authorizer.PublicKeyCreate)) - publicAPI.GET(GetPublicKeysURL, gateway.Handler(handler.GetPublicKeys)) - publicAPI.PUT(UpdatePublicKeyURL, gateway.Handler(handler.UpdatePublicKey), routesmiddleware.RequiresPermission(authorizer.PublicKeyEdit)) - publicAPI.DELETE(DeletePublicKeyURL, gateway.Handler(handler.DeletePublicKey), routesmiddleware.RequiresPermission(authorizer.PublicKeyRemove)) + publicAPI := gateway.MountOn(router, router.Group(publicAPIPrefix)) + + gateway.GET(publicAPI, HealthCheckURL, gateway.None(handler.EvaluateHealth), + gateway.Unbounded("the health check reports on the instance, which belongs to no namespace"), + gateway.Anonymous("the health check is what a load balancer asks before any credential exists")) + + gateway.GET(publicAPI, AuthLocalUserURLV2, gateway.Handler(handler.CreateUserToken)) // TODO: method POST + gateway.GET(publicAPI, AuthUserTokenPublicURL, gateway.Handler(handler.CreateUserToken), gateway.NoAPIKey()) // TODO: method POST + gateway.POST(publicAPI, AuthDeviceURL, gateway.Handler(handler.AuthDevice), + gateway.Anonymous("a device authenticates with its own credentials, and holds none before this call answers")) + gateway.POST(publicAPI, AuthDeviceURLV2, gateway.Handler(handler.AuthDevice)) + gateway.POST(publicAPI, EnrollmentCallbackURL, gateway.Handler(handler.EnrollmentCallback), + gateway.Anonymous("the enrollment provider calls back with the token it was issued, not with a ShellHub credential")) + gateway.POST(publicAPI, AuthLocalUserURL, gateway.Handler(handler.AuthLocalUser), + gateway.Anonymous("signing in is what produces a credential, so it cannot demand one")) + gateway.POST(publicAPI, AuthLocalUserURLV2, gateway.Handler(handler.AuthLocalUser)) + gateway.POST(publicAPI, AuthPublicKeyURL, gateway.Handler(handler.AuthPublicKey)) + + gateway.POST(publicAPI, CreateAPIKeyURL, gateway.Handler(handler.CreateAPIKey), gateway.NoAPIKey(), gateway.Requires(authorizer.APIKeyCreate)) + gateway.GET(publicAPI, ListAPIKeysURL, gateway.Handler(handler.ListAPIKeys), gateway.NoAPIKey()) + gateway.PATCH(publicAPI, UpdateAPIKeyURL, gateway.Handler(handler.UpdateAPIKey), gateway.NoAPIKey(), gateway.Requires(authorizer.APIKeyUpdate)) + gateway.DELETE(publicAPI, DeleteAPIKeyURL, gateway.Handler(handler.DeleteAPIKey), gateway.NoAPIKey(), gateway.Requires(authorizer.APIKeyDelete)) + + gateway.POST(publicAPI, CreateInstallKeyURL, gateway.Handler(handler.CreateInstallKey), gateway.NoAPIKey(), gateway.Requires(authorizer.InstallKeyCreate)) + gateway.GET(publicAPI, ListInstallKeysURL, gateway.Handler(handler.ListInstallKeys), gateway.NoAPIKey(), gateway.Requires(authorizer.InstallKeyList)) + gateway.PATCH(publicAPI, UpdateInstallKeyURL, gateway.Handler(handler.UpdateInstallKey), gateway.NoAPIKey(), gateway.Requires(authorizer.InstallKeyUpdate)) + gateway.GET(publicAPI, RevealInstallKeyURL, gateway.Handler(handler.RevealInstallKey), gateway.NoAPIKey(), gateway.Requires(authorizer.InstallKeyReveal)) + gateway.GET(publicAPI, HistoryInstallKeyURL, gateway.Handler(handler.HistoryInstallKey), gateway.NoAPIKey(), gateway.Requires(authorizer.InstallKeyList)) + + gateway.PATCH(publicAPI, URLUpdateUser, gateway.Handler(handler.UpdateUser), gateway.NoAPIKey()) + gateway.PATCH(publicAPI, URLDeprecatedUpdateUser, gateway.Handler(handler.UpdateUser), gateway.NoAPIKey()) // WARN: DEPRECATED. + gateway.PATCH(publicAPI, URLDeprecatedUpdateUserPassword, gateway.Handler(handler.UpdateUserPassword), gateway.NoAPIKey()) // WARN: DEPRECATED. + + gateway.POST(publicAPI, RegisterUserURL, gateway.Handler(handler.RegisterUser), + gateway.Anonymous("registering is what creates the person a credential would name")) + gateway.GET(publicAPI, URLResolveInvitation, gateway.Handler(handler.ResolveInvitation), + gateway.Anonymous("an invitee follows the link before holding an account, and the signed invitation is the credential")) + gateway.POST(publicAPI, URLGenerateInvitationLink, gateway.Handler(handler.GenerateInvitationLink), gateway.NoAPIKey(), gateway.Requires(authorizer.NamespaceAddMember)) + gateway.PATCH(publicAPI, URLAcceptInvite, gateway.Handler(handler.AcceptInvite), gateway.NoAPIKey()) + gateway.GET(publicAPI, URLUserMembershipInvitationList, gateway.Handler(handler.GetUserMembershipInvitationList)) + gateway.GET(publicAPI, URLNamespaceMembershipInvitationList, gateway.Handler(handler.GetNamespaceMembershipInvitationList), gateway.Requires(authorizer.NamespaceEditMember)) + gateway.DELETE(publicAPI, URLCancelMembershipInvitation, gateway.Handler(handler.CancelMembershipInvitation), gateway.Requires(authorizer.NamespaceRemoveMember)) + + gateway.GET(publicAPI, GetDeviceListURL, gateway.List(handler.GetDeviceList), gateway.Guard(routesmiddleware.Authorize)) + gateway.GET(publicAPI, GetDeviceURL, gateway.One(handler.GetDevice), gateway.Guard(routesmiddleware.Authorize)) + gateway.GET(publicAPI, ResolveDeviceURL, gateway.Handler(handler.ResolveDevice), gateway.Guard(routesmiddleware.Authorize)) + gateway.PUT(publicAPI, UpdateDevice, gateway.Handler(handler.UpdateDevice), gateway.Requires(authorizer.DeviceUpdate)) + gateway.PATCH(publicAPI, RenameDeviceURL, gateway.Handler(handler.RenameDevice), gateway.Requires(authorizer.DeviceRename)) + gateway.PATCH(publicAPI, UpdateDeviceStatusURL, gateway.Handler(handler.UpdateDeviceStatus), gateway.Requires(authorizer.DeviceAccept)) // TODO: DeviceWrite + + gateway.POST(publicAPI, CreateDeviceLoginCodeURL, gateway.Handler(handler.CreateDeviceLoginCode)) + gateway.GET(publicAPI, GetDeviceAuthStatusURL, gateway.Handler(handler.GetDeviceAuthStatus)) + gateway.GET(publicAPI, ResolveDeviceLoginCodeURL, gateway.Handler(handler.ResolveDeviceLoginCode), gateway.NoAPIKey()) + + gateway.POST(publicAPI, CreateDevicePairingURL, gateway.Handler(handler.CreateDevicePairing), + gateway.Anonymous("an unpaired agent asks for a pairing code with no namespace behind it yet")) + gateway.GET(publicAPI, GetDevicePairingStatusURL, gateway.Handler(handler.GetDevicePairingStatus), + gateway.Anonymous("the agent polls its own pairing code, which is the only secret the call needs")) + gateway.POST(publicAPI, AcceptDevicePairingURL, gateway.Handler(handler.AcceptDevicePairing), gateway.NoAPIKey()) + gateway.POST(publicAPI, PrepareDevicePairingURL, gateway.Handler(handler.PrepareDevicePairing), gateway.NoAPIKey(), gateway.Requires(authorizer.DeviceAccept)) + + gateway.GET(publicAPI, GetSSHApprovalURL, gateway.Handler(handler.GetSSHApproval), gateway.NoAPIKey()) + gateway.POST(publicAPI, ConfirmSSHApprovalURL, gateway.Handler(handler.ConfirmSSHApproval), gateway.NoAPIKey()) + gateway.POST(publicAPI, RejectSSHApprovalURL, gateway.Handler(handler.RejectSSHApproval), gateway.NoAPIKey()) + gateway.DELETE(publicAPI, DeleteDeviceURL, gateway.Handler(handler.DeleteDevice), gateway.Requires(authorizer.DeviceRemove)) + gateway.PUT(publicAPI, SetDeviceCustomFieldURL, gateway.Handler(handler.SetDeviceCustomField), gateway.Requires(authorizer.DeviceCustomFieldUpdate)) + gateway.DELETE(publicAPI, DeleteDeviceCustomFieldURL, gateway.Handler(handler.DeleteDeviceCustomField), gateway.Requires(authorizer.DeviceCustomFieldUpdate)) + + gateway.GET(publicAPI, URLGetTags, gateway.Handler(handler.GetTags)) + gateway.POST(publicAPI, URLCreateTag, gateway.Handler(handler.CreateTag), gateway.Requires(authorizer.TagCreate)) + gateway.PATCH(publicAPI, URLUpdateTag, gateway.Handler(handler.UpdateTag), gateway.Requires(authorizer.TagUpdate)) + gateway.DELETE(publicAPI, URLDeleteTag, gateway.Handler(handler.DeleteTag), gateway.Requires(authorizer.TagDelete)) + gateway.POST(publicAPI, URLPushTagToDevice, gateway.Handler(handler.PushTagToDevice), gateway.Requires(authorizer.TagCreate)) + gateway.DELETE(publicAPI, URLPullTagFromDevice, gateway.Handler(handler.PullTagFromDevice), gateway.Requires(authorizer.TagDelete)) + + gateway.GET(publicAPI, URLOldGetTags, gateway.Handler(handler.GetTags)) + gateway.POST(publicAPI, URLOldCreateTag, gateway.Handler(handler.CreateTag), gateway.Requires(authorizer.TagCreate)) + gateway.PATCH(publicAPI, URLOldUpdateTag, gateway.Handler(handler.UpdateTag), gateway.Requires(authorizer.TagUpdate)) + gateway.DELETE(publicAPI, URLOldDeleteTag, gateway.Handler(handler.DeleteTag), gateway.Requires(authorizer.TagDelete)) + gateway.POST(publicAPI, URLOldPushTagToDevice, gateway.Handler(handler.PushTagToDevice), gateway.Requires(authorizer.TagCreate)) + gateway.DELETE(publicAPI, URLOldPullTagFromDevice, gateway.Handler(handler.PullTagFromDevice), gateway.Requires(authorizer.TagDelete)) + + gateway.GET(publicAPI, GetSessionsURL, gateway.Handler(handler.GetSessionList), gateway.Guard(routesmiddleware.Authorize)) + gateway.GET(publicAPI, GetSessionURL, gateway.Handler(handler.GetSession), gateway.Guard(routesmiddleware.Authorize)) + + gateway.GET(publicAPI, GetStatsURL, gateway.Handler(handler.GetStats), gateway.Guard(routesmiddleware.Authorize)) + gateway.GET(publicAPI, GetSystemInfoURL, gateway.Handler(handler.GetSystemInfo), + gateway.Anonymous("the instance describes itself to a browser that has not signed in yet")) + gateway.GET(publicAPI, GetSystemDownloadInstallScriptURL, gateway.Handler(handler.GetSystemDownloadInstallScript), + gateway.Anonymous("the install script is fetched by a shell on a machine that holds no credential")) + + gateway.POST(publicAPI, CreatePublicKeyURL, gateway.Handler(handler.CreatePublicKey), gateway.Requires(authorizer.PublicKeyCreate)) + gateway.GET(publicAPI, GetPublicKeysURL, gateway.Handler(handler.GetPublicKeys)) + gateway.PUT(publicAPI, UpdatePublicKeyURL, gateway.Handler(handler.UpdatePublicKey), gateway.Requires(authorizer.PublicKeyEdit)) + gateway.DELETE(publicAPI, DeletePublicKeyURL, gateway.Handler(handler.DeletePublicKey), gateway.Requires(authorizer.PublicKeyRemove)) if envs.IsEnterpriseOrCloud() { - publicAPI.POST(CreateNamespaceURL, gateway.Handler(handler.CreateNamespace), routesmiddleware.BlockAPIKey) + gateway.POST(publicAPI, CreateNamespaceURL, gateway.Handler(handler.CreateNamespace), gateway.NoAPIKey()) } - publicAPI.GET(GetNamespaceURL, gateway.Handler(handler.GetNamespace), routesmiddleware.RequiresTenant(ParamNamespaceTenant)) - publicAPI.GET(ListNamespaceURL, gateway.Handler(handler.GetNamespaceList), routesmiddleware.BlockAPIKey) - publicAPI.PUT(EditNamespaceURL, gateway.Handler(handler.EditNamespace), routesmiddleware.RequiresTenant(ParamNamespaceTenant), routesmiddleware.RequiresPermission(authorizer.NamespaceUpdate)) - publicAPI.DELETE(DeleteNamespaceURL, gateway.Handler(handler.DeleteNamespace), routesmiddleware.RequiresTenant(ParamNamespaceTenant), routesmiddleware.RequiresPermission(authorizer.NamespaceDelete)) + gateway.GET(publicAPI, GetNamespaceURL, gateway.Handler(handler.GetNamespace), gateway.Guard(routesmiddleware.RequiresTenant(ParamNamespaceTenant))) + gateway.GET(publicAPI, ListNamespaceURL, gateway.Handler(handler.GetNamespaceList), gateway.NoAPIKey()) + gateway.PUT(publicAPI, EditNamespaceURL, gateway.Handler(handler.EditNamespace), gateway.Guard(routesmiddleware.RequiresTenant(ParamNamespaceTenant)), gateway.Requires(authorizer.NamespaceUpdate)) + gateway.DELETE(publicAPI, DeleteNamespaceURL, gateway.Handler(handler.DeleteNamespace), gateway.Guard(routesmiddleware.RequiresTenant(ParamNamespaceTenant)), gateway.Requires(authorizer.NamespaceDelete)) - publicAPI.GET(ListNamespaceMembersURL, gateway.Handler(handler.ListNamespaceMembers), routesmiddleware.RequiresTenant(ParamNamespaceTenant)) - publicAPI.POST(AddNamespaceMemberURL, gateway.Handler(handler.AddNamespaceMember), routesmiddleware.RequiresPermission(authorizer.NamespaceAddMember)) - publicAPI.PATCH(EditNamespaceMemberURL, gateway.Handler(handler.EditNamespaceMember), routesmiddleware.RequiresPermission(authorizer.NamespaceEditMember)) - publicAPI.DELETE(RemoveNamespaceMemberURL, gateway.Handler(handler.RemoveNamespaceMember), routesmiddleware.RequiresPermission(authorizer.NamespaceRemoveMember)) - publicAPI.DELETE(LeaveNamespaceURL, gateway.Handler(handler.LeaveNamespace), routesmiddleware.BlockAPIKey) + gateway.GET(publicAPI, ListNamespaceMembersURL, gateway.Handler(handler.ListNamespaceMembers), gateway.Guard(routesmiddleware.RequiresTenant(ParamNamespaceTenant))) + gateway.POST(publicAPI, AddNamespaceMemberURL, gateway.Handler(handler.AddNamespaceMember), gateway.Requires(authorizer.NamespaceAddMember)) + gateway.PATCH(publicAPI, EditNamespaceMemberURL, gateway.Handler(handler.EditNamespaceMember), gateway.Requires(authorizer.NamespaceEditMember)) + gateway.DELETE(publicAPI, RemoveNamespaceMemberURL, gateway.Handler(handler.RemoveNamespaceMember), gateway.Requires(authorizer.NamespaceRemoveMember)) + gateway.DELETE(publicAPI, LeaveNamespaceURL, gateway.Handler(handler.LeaveNamespace), gateway.NoAPIKey()) - publicAPI.PUT(EditSessionRecordStatusURL, gateway.Handler(handler.EditSessionRecordStatus), routesmiddleware.RequiresTenant(ParamNamespaceTenant), routesmiddleware.RequiresPermission(authorizer.NamespaceEnableSessionRecord)) - publicAPI.PUT(EditSSHAccessModeURL, gateway.Handler(handler.EditSSHAccessMode), routesmiddleware.RequiresTenant(ParamNamespaceTenant), routesmiddleware.RequiresPermission(authorizer.NamespaceUpdate)) + gateway.PUT(publicAPI, EditSessionRecordStatusURL, gateway.Handler(handler.EditSessionRecordStatus), gateway.Guard(routesmiddleware.RequiresTenant(ParamNamespaceTenant)), gateway.Requires(authorizer.NamespaceEnableSessionRecord)) + gateway.PUT(publicAPI, EditSSHAccessModeURL, gateway.Handler(handler.EditSSHAccessMode), gateway.Guard(routesmiddleware.RequiresTenant(ParamNamespaceTenant)), gateway.Requires(authorizer.NamespaceUpdate)) - publicAPI.GET(ListAccessPoliciesURL, gateway.Handler(handler.ListAccessPolicies), routesmiddleware.RequiresPermission(authorizer.AccessPolicyManage)) - publicAPI.POST(CreateAccessPolicyURL, gateway.Handler(handler.CreateAccessPolicy), routesmiddleware.RequiresPermission(authorizer.AccessPolicyManage)) - publicAPI.GET(GetAccessPolicyURL, gateway.Handler(handler.GetAccessPolicy), routesmiddleware.RequiresPermission(authorizer.AccessPolicyManage)) - publicAPI.PUT(UpdateAccessPolicyURL, gateway.Handler(handler.UpdateAccessPolicy), routesmiddleware.RequiresPermission(authorizer.AccessPolicyManage)) - publicAPI.DELETE(DeleteAccessPolicyURL, gateway.Handler(handler.DeleteAccessPolicy), routesmiddleware.RequiresPermission(authorizer.AccessPolicyManage)) + gateway.GET(publicAPI, ListAccessPoliciesURL, gateway.Handler(handler.ListAccessPolicies), gateway.Requires(authorizer.AccessPolicyManage)) + gateway.POST(publicAPI, CreateAccessPolicyURL, gateway.Handler(handler.CreateAccessPolicy), gateway.Requires(authorizer.AccessPolicyManage)) + gateway.GET(publicAPI, GetAccessPolicyURL, gateway.Handler(handler.GetAccessPolicy), gateway.Requires(authorizer.AccessPolicyManage)) + gateway.PUT(publicAPI, UpdateAccessPolicyURL, gateway.Handler(handler.UpdateAccessPolicy), gateway.Requires(authorizer.AccessPolicyManage)) + gateway.DELETE(publicAPI, DeleteAccessPolicyURL, gateway.Handler(handler.DeleteAccessPolicy), gateway.Requires(authorizer.AccessPolicyManage)) - publicAPI.GET(ListSSHIdentitiesURL, gateway.Handler(handler.ListSSHIdentities)) - publicAPI.POST(CreateSSHIdentityURL, gateway.Handler(handler.CreateSSHIdentity), routesmiddleware.RequiresPermission(authorizer.SSHIdentityAdd)) - publicAPI.PATCH(UpdateSSHIdentityURL, gateway.Handler(handler.UpdateSSHIdentity), routesmiddleware.RequiresPermission(authorizer.SSHIdentityAdd)) - publicAPI.DELETE(DeleteSSHIdentityURL, gateway.Handler(handler.DeleteSSHIdentity)) + gateway.GET(publicAPI, ListSSHIdentitiesURL, gateway.Handler(handler.ListSSHIdentities)) + gateway.POST(publicAPI, CreateSSHIdentityURL, gateway.Handler(handler.CreateSSHIdentity), gateway.Requires(authorizer.SSHIdentityAdd)) + gateway.PATCH(publicAPI, UpdateSSHIdentityURL, gateway.Handler(handler.UpdateSSHIdentity), gateway.Requires(authorizer.SSHIdentityAdd)) + gateway.DELETE(publicAPI, DeleteSSHIdentityURL, gateway.Handler(handler.DeleteSSHIdentity)) - publicAPI.POST(WebReauthURL, gateway.Handler(handler.WebReauthVerify)) + gateway.POST(publicAPI, WebReauthURL, gateway.Handler(handler.WebReauthVerify)) - publicAPI.GET(ListServiceAccountsURL, gateway.Handler(handler.ListServiceAccounts), routesmiddleware.RequiresPermission(authorizer.NamespaceAddMember)) - publicAPI.POST(CreateServiceAccountURL, gateway.Handler(handler.CreateServiceAccount), routesmiddleware.RequiresPermission(authorizer.NamespaceAddMember)) - publicAPI.DELETE(DeleteServiceAccountURL, gateway.Handler(handler.DeleteServiceAccount), routesmiddleware.RequiresPermission(authorizer.NamespaceAddMember)) + gateway.GET(publicAPI, ListServiceAccountsURL, gateway.Handler(handler.ListServiceAccounts), gateway.Requires(authorizer.NamespaceAddMember)) + gateway.POST(publicAPI, CreateServiceAccountURL, gateway.Handler(handler.CreateServiceAccount), gateway.Requires(authorizer.NamespaceAddMember)) + gateway.DELETE(publicAPI, DeleteServiceAccountURL, gateway.Handler(handler.DeleteServiceAccount), gateway.Requires(authorizer.NamespaceAddMember)) if !envs.IsCloud() { - publicAPI.POST(SetupEndpoint, gateway.Handler(handler.Setup)) + gateway.POST(publicAPI, SetupEndpoint, gateway.Handler(handler.Setup), + gateway.Anonymous("the first administrator is created before anyone can hold a credential")) } SetupMCPRoutes(router)