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)