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
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,5 @@ tool (
google.golang.org/grpc/cmd/protoc-gen-go-grpc
google.golang.org/protobuf/cmd/protoc-gen-go
)

replace github.com/DIMO-Network/dauth => ../dauth
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/DIMO-Network/cloudevent v1.1.0 h1:pDJxwO3/Zom/U8BOZqKtMQ05Ye6Pgn6LAh/Dlr8VwWA=
github.com/DIMO-Network/cloudevent v1.1.0/go.mod h1:I/9NcpMozV5Fw194WimhbkAsJtKVZf5UKYJ9hgc8Cdg=
github.com/DIMO-Network/dauth v0.0.1 h1:ZDPNOfCRci1+RhpgMz/2LXUywXjYdSeXRDl+J3M+/5A=
github.com/DIMO-Network/dauth v0.0.1/go.mod h1:RWy1bbPI0KWJ/Q5eS5teKo7jP6sNPWLomK0SXKkhdoo=
github.com/DIMO-Network/model-garage v1.0.11 h1:aLvIyeo58p9pVgz+d3DnU5k5Fxvxh6mq/jE2s3LxXoc=
github.com/DIMO-Network/model-garage v1.0.11/go.mod h1:oi7EGKQVxFVpXRsu2H+YbizbKcx06aQg2N1Yu4GqOp8=
github.com/DIMO-Network/server-garage v0.4.0 h1:3ukXvtldIhLldn9AtbtQBooBjT2gicvB3WphrsjNQho=
Expand Down
15 changes: 10 additions & 5 deletions internal/auth/directives.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import (
"context"
"errors"
"fmt"
"slices"

"github.com/99designs/gqlgen/graphql"
"github.com/DIMO-Network/dq/internal/scope"
)

const didArg = "subject"
Expand Down Expand Up @@ -57,28 +57,33 @@ func NewVehicleTokenCheck() func(context.Context, any, graphql.Resolver) (any, e
}
}

// AllOfPrivilegeCheck verifies the claim includes ALL of the required privilege strings.
// AllOfPrivilegeCheck verifies the claim includes ALL of the required privilege
// strings. A permission held under constraints (scoped_permissions) counts as
// held here — this directive is a possession gate only; the data window is
// enforced where each query touches data (range checks on ranged resolvers,
// per-value timestamp checks on latest paths).
func AllOfPrivilegeCheck(ctx context.Context, _ any, next graphql.Resolver, requiredPrivs []string) (any, error) {
claim, err := getDQClaim(ctx)
if err != nil {
return nil, UnauthorizedError{err: err}
}
for _, priv := range requiredPrivs {
if !slices.Contains(claim.Permissions, priv) {
if !scope.Holds(&claim.Token, priv) {
return nil, newError("missing required privilege %s", priv)
}
}
return next(ctx)
}

// OneOfPrivilegeCheck verifies the claim includes AT LEAST ONE of the required privilege strings.
// OneOfPrivilegeCheck verifies the claim includes AT LEAST ONE of the required
// privilege strings, scoped or not (see AllOfPrivilegeCheck on scoped grants).
func OneOfPrivilegeCheck(ctx context.Context, _ any, next graphql.Resolver, requiredPrivs []string) (any, error) {
claim, err := getDQClaim(ctx)
if err != nil {
return nil, UnauthorizedError{err: err}
}
for _, priv := range requiredPrivs {
if slices.Contains(claim.Permissions, priv) {
if scope.Holds(&claim.Token, priv) {
return next(ctx)
}
}
Expand Down
9 changes: 7 additions & 2 deletions internal/auth/grpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,13 @@ func bearerFromMetadata(ctx context.Context) string {
return tok
}

// grpcHasRawDataAccess mirrors graph.hasRawDataAccess: a token may read raw data
// with the explicit get-raw-data permission, or with both history permissions.
// grpcHasRawDataAccess reports raw-data access: the explicit get-raw-data
// permission, or both history permissions.
//
// Deliberately reads only the flat permissions claim: a permission granted
// under a data window (scoped_permissions) does NOT open this surface, because
// the fetch RPCs have no window enforcement yet. The claim encoding makes that
// fail-closed by construction — scoped grants are invisible here.
func grpcHasRawDataAccess(perms []string) bool {
if slices.Contains(perms, tokenclaims.PermissionGetRawData) {
return true
Expand Down
34 changes: 32 additions & 2 deletions internal/graph/arguments.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import (

"github.com/99designs/gqlgen/graphql"
"github.com/DIMO-Network/dq/internal/graph/model"
"github.com/DIMO-Network/dq/internal/repositories"
"github.com/DIMO-Network/model-garage/pkg/vss"
)

// aggregationArgsFromContext creates aggregated signals arguments from the context and provided arguments.
func aggregationArgsFromContext(ctx context.Context, did string, interval string, from time.Time, to time.Time, filter *model.SignalFilter) (*model.AggregatedSignalArgs, error) {
func aggregationArgsFromContext(ctx context.Context, repo *repositories.Repository, did string, interval string, from time.Time, to time.Time, filter *model.SignalFilter) (*model.AggregatedSignalArgs, error) {
intervalInt, err := getIntervalMicroseconds(interval)
if err != nil {
return nil, err
Expand All @@ -26,12 +27,24 @@ func aggregationArgsFromContext(ctx context.Context, did string, interval string
Interval: intervalInt,
}

tok := tokenFromCtx(ctx)
fields := graphql.CollectFieldsCtx(ctx, nil)
parentCtx := graphql.GetFieldContext(ctx)
for _, field := range fields {
if !isSignal(field) || !hasAggregations(field) {
continue
}
// Possession is checked by the field's privilege directive; this rejects
// requested ranges outside a HELD scoped permission's data window.
// Rejection — not silent clamping — because an aggregate computed over
// a narrower range than requested would be mislabeled as covering the
// full range.
if hasScopedPermissions(tok) && !signalRangeWithinWindows(repo, field.Name, tok, from, to) {
if desc := signalWindowDescription(repo, field.Name, tok); desc != "" {
return nil, fmt.Errorf("unauthorized: requested range for signal %s is outside the token's data window: %s", field.Name, desc)
}
return nil, fmt.Errorf("unauthorized: token does not allow signal %s over the requested range", field.Name)
}
child, err := parentCtx.Child(ctx, field)
if err != nil {
return nil, fmt.Errorf("failed to get child field: %w", err)
Expand Down Expand Up @@ -84,7 +97,7 @@ func addSignalAggregation(aggArgs *model.AggregatedSignalArgs, child *graphql.Fi
}

// latestArgsFromContext creates latest signals arguments from the context and provided arguments.
func latestArgsFromContext(ctx context.Context, did string, filter *model.SignalFilter) (*model.LatestSignalsArgs, error) {
func latestArgsFromContext(ctx context.Context, repo *repositories.Repository, did string, filter *model.SignalFilter) (*model.LatestSignalsArgs, error) {
fields := graphql.CollectFieldsCtx(ctx, nil)
latestArgs := model.LatestSignalsArgs{
SignalArgs: model.SignalArgs{
Expand All @@ -110,6 +123,23 @@ func latestArgsFromContext(ctx context.Context, did string, filter *model.Signal
latestArgs.SignalNames[field.Name] = struct{}{}
}
}
if tok := tokenFromCtx(ctx); hasScopedPermissions(tok) {
// Latest values are point queries: rather than rejecting, they are
// evaluated under the window — a value recorded outside it is withheld,
// which is indistinguishable from the vehicle not having transmitted
// then. Possession stays with the field directives; these hooks only
// enforce the windows.
latestArgs.RowAllowed = func(name string, ts time.Time) bool {
return signalValueVisible(repo, name, tok, ts)
}
latestArgs.ApproxLocationAllowed = func(ts time.Time) bool {
return approxLocationVisible(tok, ts)
}
// lastSeen is computed across every signal the vehicle has, so it can
// reveal activity outside the window; suppressed for scoped tokens
// until it is window-aware.
latestArgs.IncludeLastSeen = false
}
return &latestArgs, nil
}

Expand Down
73 changes: 59 additions & 14 deletions internal/graph/auth_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ package graph
import (
"context"
"fmt"
"slices"
"time"

"github.com/DIMO-Network/cloudevent"
"github.com/DIMO-Network/dauth/pkg/tokenclaims"
"github.com/DIMO-Network/dq/internal/graph/model"
"github.com/DIMO-Network/dq/internal/scope"
"github.com/DIMO-Network/dq/pkg/grpc"
"github.com/DIMO-Network/dauth/pkg/tokenclaims"
)

const (
Expand All @@ -18,7 +19,7 @@ const (
)

func (r *queryResolver) requireSubjectOptsByDID(ctx context.Context, requestedDID string, filter *model.CloudEventFilter) (*grpc.AdvancedSearchOptions, error) {
token, err := requireRawDataToken(ctx)
token, err := requireRawDataToken(ctx, filter)
if err != nil {
return nil, err
}
Expand All @@ -30,25 +31,69 @@ func (r *queryResolver) requireSubjectOptsByDID(ctx context.Context, requestedDI
return filterToAdvancedSearchOptions(filter, searchSubject), nil
}

func requireRawDataToken(ctx context.Context) (*tokenclaims.Token, error) {
// requireRawDataToken authorizes a cloud-event read. Raw-data access is
// granted by the explicit GetRawData permission, or by holding both history
// permissions (all-time history implies raw-data access).
//
// When every qualifying permission is unconditional the read is unrestricted,
// matching the historical behavior. When the access derives from scoped
// permissions, the request must carry explicit after/before bounds that sit
// inside the data window — cloud-event queries are ranged reads, so a range
// wider than the window (including the implicit "all time" of an unbounded
// filter) is rejected rather than silently narrowed.
func requireRawDataToken(ctx context.Context, filter *model.CloudEventFilter) (*tokenclaims.Token, error) {
tok, _ := ctx.Value(ClaimsContextKey{}).(*tokenclaims.Token)
if tok == nil {
return nil, fmt.Errorf("%s", errNoTokenClaims)
}
if !hasRawDataAccess(tok.Permissions) {
if hasUnscopedRawDataAccess(tok) {
return tok, nil
}

rawDataHeld := scope.Holds(tok, tokenclaims.PermissionGetRawData)
historyHeld := scope.Holds(tok, tokenclaims.PermissionGetLocationHistory) &&
scope.Holds(tok, tokenclaims.PermissionGetNonLocationHistory)
if !rawDataHeld && !historyHeld {
return nil, fmt.Errorf("%s", errNoPermission)
}
return tok, nil

from, to := requestedEventRange(filter)
if rawDataHeld && scope.AllowsRange(tok, tokenclaims.PermissionGetRawData, from, to) {
return tok, nil
}
if historyHeld &&
scope.AllowsRange(tok, tokenclaims.PermissionGetLocationHistory, from, to) &&
scope.AllowsRange(tok, tokenclaims.PermissionGetNonLocationHistory, from, to) {
return tok, nil
}
return nil, fmt.Errorf("unauthorized: the token's raw-data access is limited to a data window; the request's after/before bounds must sit inside it")
}

// hasRawDataAccess reports whether perms grant raw-data access: either the
// explicit GetRawData permission, or both location- and non-location-history
// (holding all-time history implies raw-data access).
func hasRawDataAccess(perms []string) bool {
hasGetRawData := slices.Contains(perms, tokenclaims.PermissionGetRawData)
hasAllTimeData := slices.Contains(perms, tokenclaims.PermissionGetLocationHistory) &&
slices.Contains(perms, tokenclaims.PermissionGetNonLocationHistory)
return hasGetRawData || hasAllTimeData
// requestedEventRange resolves the half-open interval a cloud-event filter
// could touch. Missing bounds widen to the extremes so an unbounded request
// only passes an unbounded grant.
func requestedEventRange(filter *model.CloudEventFilter) (from, to time.Time) {
from = time.Unix(0, 0).UTC()
to = time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC)
if filter != nil {
if filter.After != nil {
from = *filter.After
}
if filter.Before != nil {
to = *filter.Before
}
}
return from, to
}

// hasUnscopedRawDataAccess reports whether raw-data access is granted
// unconditionally: either path composed entirely of unscoped permissions.
func hasUnscopedRawDataAccess(tok *tokenclaims.Token) bool {
if scope.Unscoped(tok, tokenclaims.PermissionGetRawData) {
return true
}
return scope.Unscoped(tok, tokenclaims.PermissionGetLocationHistory) &&
scope.Unscoped(tok, tokenclaims.PermissionGetNonLocationHistory)
}

func (r *queryResolver) ensureRequestedDIDLinkedToPermissionedSubject(ctx context.Context, requestedDID string, tokenSubjectDID string) (string, error) {
Expand Down
75 changes: 66 additions & 9 deletions internal/graph/base.resolvers.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading