Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions pkg/api/query/contract.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package query

// Contract is what one resource's list endpoint accepts from a client: the filter fields with the
// operators valid against each, the fields it may be ordered by, and the order it is served in when
// the client names none.
//
// The zero value accepts no filter and no sort, which is what a resource taking neither declares —
// an omitted contract is indistinguishable from a forgotten one, so there is no such thing.
type Contract struct {
Filter FieldConstraints
Sort FieldSet

// DefaultSort is applied to a sorter the client left blank. It is applied before the sort is
// validated, so a default naming a field outside Sort is refused exactly as a client's would be.
DefaultSort Sorter
}

// NormalizeSorter fills from the contract's default whatever the client left blank, then holds the
// order to asc or desc. A zero contract leaves the sorter as [Sorter.Normalize] would.
func (c Contract) NormalizeSorter(sorter *Sorter) {
if sorter == nil {
return
}

if sorter.By == "" {
sorter.By = c.DefaultSort.By
}

if sorter.Order == "" {
sorter.Order = c.DefaultSort.Order
}

sorter.Normalize()
}
12 changes: 12 additions & 0 deletions pkg/api/query/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@ func (fs *Filters) Unmarshal() error {
return nil
}

// Filtered is a request that carries a [Filters]. It is the filtering counterpart of [Paginated]
// and [Sorted], and exists for the same reason: it lets a caller holding only the request decode
// the filter without knowing the request's concrete type.
type Filtered interface {
GetFilters() *Filters
}

// GetFilters returns the filters themselves, satisfying [Filtered] for every type that embeds it.
func (fs *Filters) GetFilters() *Filters {
return fs
}

// Filter is one node of a query filter: a tagged union whose Type picks the shape of Params.
// Unmarshal one rather than building it by hand, or Params holds a map instead of a params struct.
type Filter struct {
Expand Down
13 changes: 13 additions & 0 deletions server/api/pkg/gateway/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package gateway
import (
"context"

"github.com/shellhub-io/shellhub/pkg/api/authorizer"
"github.com/shellhub-io/shellhub/pkg/models"
)

Expand All @@ -25,6 +26,18 @@ func TenantFromContext(ctx context.Context) *models.Tenant {
return nil
}

// RoleFromContext returns the role the request authenticated with, or the zero role when it carries
// none. It is what a handler taking no gateway [Context] reads to widen or narrow what it serves by
// the caller's authority — a decision the route's permission cannot express, because the route is
// reachable either way.
func RoleFromContext(ctx context.Context) authorizer.Role {
if c, ok := ctx.Value("ctx").(*Context); ok {
return c.Role()
}

return authorizer.RoleInvalid
}

// UsernameFromContext returns the authenticated username, or nil when the request is
// anonymous or authenticated by an API key.
func UsernameFromContext(ctx context.Context) *models.Username {
Expand Down
64 changes: 58 additions & 6 deletions server/api/pkg/gateway/route.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,8 @@ func prepare[T any](c *echo.Context, declaration Declaration) (inputs[T], error)
return inputs[T]{}, err
}

if paginated, ok := any(req).(query.Paginated); ok {
paginated.GetPaginator().Normalize()
}

if sorted, ok := any(req).(query.Sorted); ok {
sorted.GetSorter().Normalize()
if err := applyQuery(req, declaration); err != nil {
return inputs[T]{}, err
}

if err := c.Validate(req); err != nil {
Expand All @@ -177,6 +173,42 @@ 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
}

func applyQuery[T any](req *T, declaration Declaration) error {
if paginated, ok := any(req).(query.Paginated); ok {
paginated.GetPaginator().Normalize()
}

sorted, sorts := any(req).(query.Sorted)
if sorts {
declaration.Query.NormalizeSorter(sorted.GetSorter())
}

if !declaration.AcceptsQuery {
return nil
}

if filtered, ok := any(req).(query.Filtered); ok {
filters := filtered.GetFilters()

if err := filters.Unmarshal(); err != nil {
return routes.NewErrInvalidEntity(map[string]string{"filter": "cannot be decoded"})
}

if err := query.ValidateFilters(filters, declaration.Query.Filter); err != nil {
return routes.NewErrInvalidEntity(map[string]string{"filter": "is not valid"})
}
}

if sorts {
sorter := sorted.GetSorter()
if err := query.ValidateSorter(sorter, declaration.Query.Sort); err != nil {
return routes.NewErrInvalidEntity(map[string]string{"sort_by": sorter.By})
}
}

return nil
}

// 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.
Expand Down Expand Up @@ -230,6 +262,20 @@ func NoAPIKey() RouteOption {
}
}

// Accepts declares the query contract a list route holds a client's filter and sort to, and
// installs no guard: the wrapper enforces it, before the handler is called and in one fixed order.
//
// A resource that accepts neither a filter nor a sort names a contract allowing nothing rather than
// leaving the option off, because an omission cannot be told from a forgotten one — which is the
// failure the route table's invariant exists to catch.
func Accepts(contract query.Contract) RouteOption {
return func(d *Declaration) echo.MiddlewareFunc {
d.Query, d.AcceptsQuery = contract, true

return nil
}
}

// 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.
Expand Down Expand Up @@ -265,6 +311,12 @@ type Declaration struct {
// route's permission is refused all the same.
BlocksAPIKey bool

// Query is the filter and sort fields the route accepts from a client, and the order it serves
// when the client names none. The zero value accepts neither, so AcceptsQuery is what tells a
// route deliberately taking no query from one that named no contract at all.
Query query.Contract
AcceptsQuery bool

Unbounded bool
UnboundedReason string

Expand Down
137 changes: 137 additions & 0 deletions server/api/pkg/gateway/route_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ package gateway_test

import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/labstack/echo/v5"
"github.com/shellhub-io/shellhub/pkg/api/query"
"github.com/shellhub-io/shellhub/pkg/api/responses"
"github.com/shellhub-io/shellhub/pkg/api/scope"
"github.com/shellhub-io/shellhub/pkg/errors"
"github.com/shellhub-io/shellhub/server/api/pkg/echo/handlers"
Expand All @@ -23,6 +27,7 @@ type probeRequest struct {
Name string `query:"name" validate:"omitempty,min=3"`
query.Paginator
query.Sorter
query.Filters
}

type probeCall struct {
Expand Down Expand Up @@ -372,3 +377,135 @@ func TestDeclarationsRecordEveryClaim(t *testing.T) {

assert.True(t, found, "the wrapper recorded no declaration for the probe route")
}

// TestListHoldsTheQueryToTheContractItsRegistrationNamed drives the contract mechanism once,
// through a mounted route: the wrapper is what decodes the filter and refuses a field the
// resource does not allow, so no handler opens with a validation preamble.
func TestListHoldsTheQueryToTheContractItsRegistrationNamed(t *testing.T) {
contract := query.Contract{
Filter: query.NewFieldConstraints(map[string][]string{"name": {"contains"}}),
Sort: query.NewFieldSet("name", "created_at"),
DefaultSort: query.Sorter{By: "created_at", Order: query.OrderAsc},
}

cases := []struct {
description string
target string
expectedStatus int
expectedFields map[string]string
assert func(*testing.T, *probeCall)
}{
{
description: "refuses a filter naming a field the contract does not allow",
target: "/probe?filter=" + encodeProbeFilter(t, "signature", "contains"),
expectedStatus: http.StatusBadRequest,
expectedFields: map[string]string{"filter": "is not valid"},
},
{
description: "refuses a filter naming an operator the contract does not allow",
target: "/probe?filter=" + encodeProbeFilter(t, "name", "eq"),
expectedStatus: http.StatusBadRequest,
expectedFields: map[string]string{"filter": "is not valid"},
},
{
description: "refuses a filter that is not base64",
target: "/probe?filter=not-base64!!",
expectedStatus: http.StatusBadRequest,
expectedFields: map[string]string{"filter": "cannot be decoded"},
},
{
description: "refuses a filter larger than the cap",
target: "/probe?filter=" + strings.Repeat("A", query.MaxFilterRawBytes+1),
expectedStatus: http.StatusBadRequest,
expectedFields: map[string]string{"filter": "cannot be decoded"},
},
{
description: "refuses a sort naming a field the contract does not allow",
target: "/probe?sort_by=secret",
expectedStatus: http.StatusBadRequest,
expectedFields: map[string]string{"sort_by": "secret"},
},
{
description: "hands the handler a decoded filter, a normalized page and the contract's default sort",
target: "/probe?page=0&per_page=999&filter=" + encodeProbeFilter(t, "name", "contains"),
expectedStatus: http.StatusOK,
assert: func(t *testing.T, call *probeCall) {
t.Helper()

require.Len(t, call.req.Filters.Data, 1)
assert.Equal(t, &query.FilterProperty{Name: "name", Operator: "contains", Value: "value"}, call.req.Filters.Data[0].Params)
assert.Equal(t, query.MinPage, call.req.Paginator.Page)
assert.Equal(t, query.MaxPerPage, call.req.Paginator.PerPage)
assert.Equal(t, "created_at", call.req.Sorter.By)
assert.Equal(t, query.OrderAsc, call.req.Sorter.Order)
},
},
{
description: "leaves a sort the client asked for alone when the contract allows it",
target: "/probe?sort_by=name&order_by=asc",
expectedStatus: http.StatusOK,
assert: func(t *testing.T, call *probeCall) {
t.Helper()

assert.Equal(t, "name", call.req.Sorter.By)
},
},
}

for _, tc := range cases {
t.Run(tc.description, func(t *testing.T) {
call := new(probeCall)

e := probeRouter(t, true)
gateway.GET(rootOf(e), "/probe", gateway.List(probeHandler(call, []string{"item"}, 1, nil)), gateway.Accepts(contract))

req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, tc.target, nil)
req.Header.Set("X-Tenant-ID", probeTenant)
req.Header.Set("X-ID", "user-id")

rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)

require.Equal(t, tc.expectedStatus, rec.Code, rec.Body.String())
require.Equal(t, tc.expectedStatus == http.StatusOK, call.called)

if tc.expectedFields != nil {
var body responses.Error
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
assert.Equal(t, tc.expectedFields, body.Fields)
}

if tc.assert != nil {
tc.assert(t, call)
}
})
}
}

// TestAcceptsRecordsTheContractOnTheDeclaration is what the route table's invariant reads: a claim
// the wrapper enforces still has to be visible to an audit of the declarations.
func TestAcceptsRecordsTheContractOnTheDeclaration(t *testing.T) {
e := probeRouter(t, true)
gateway.GET(rootOf(e), "/accepting", gateway.List(probeHandler(new(probeCall), nil, 0, nil)),
gateway.Accepts(query.Contract{Sort: query.NewFieldSet("name")}))
gateway.GET(rootOf(e), "/silent", gateway.List(probeHandler(new(probeCall), nil, 0, nil)))

accepts := make(map[string]bool)
for _, declaration := range gateway.Declarations(e) {
accepts[declaration.Path] = declaration.AcceptsQuery
}

assert.True(t, accepts["/accepting"])
assert.False(t, accepts["/silent"])
}

func encodeProbeFilter(t *testing.T, name, operator string) string {
t.Helper()

encoded, err := json.Marshal([]query.Filter{
{Type: query.FilterTypeProperty, Params: &query.FilterProperty{Name: name, Operator: operator, Value: "value"}},
})
require.NoError(t, err)

return base64.RawURLEncoding.EncodeToString(encoded)
}
20 changes: 5 additions & 15 deletions server/api/routes/access-policy.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package routes

import (
"context"
"net/http"
"strconv"

"github.com/shellhub-io/shellhub/pkg/api/requests"
"github.com/shellhub-io/shellhub/pkg/api/scope"
"github.com/shellhub-io/shellhub/pkg/models"
"github.com/shellhub-io/shellhub/server/api/pkg/gateway"
)

Expand All @@ -18,20 +20,8 @@ const (
)

// ListAccessPolicies serves the policy list for the caller's namespace.
func (h *Handler) ListAccessPolicies(c *gateway.Context) error {
var tenant string
if c.Tenant() != nil {
tenant = c.Tenant().ID
}

list, err := h.service.ListAccessPolicies(c.Ctx(), tenant)
if err != nil {
return err
}

c.Response().Header().Set("X-Total-Count", strconv.Itoa(len(list)))

return c.JSON(http.StatusOK, list)
func (h *Handler) ListAccessPolicies(ctx context.Context, sc scope.Scope, _ gateway.Actor, _ *requests.AccessPolicyList) ([]models.AccessPolicy, int, error) {
return h.service.ListAccessPolicies(ctx, sc.TenantID())
}

// GetAccessPolicy serves a single policy by id.
Expand Down
Loading
Loading