From c506a7e7d2da18dd93ac512b8c334d0fe8d02bbd Mon Sep 17 00:00:00 2001 From: Dylan Moreland Date: Fri, 17 Jul 2026 08:52:31 -0400 Subject: [PATCH 1/2] Enforce scoped permissions (ODRL data windows) from dauth tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dauth's exchange can now mint permissions granted only for data recorded within a time window (DIMO ODRL profile v1, dimo:recordedAt). Such permissions arrive in a new scoped_permissions claim, deliberately absent from the flat permissions array, so before this change dq refused windowed tokens everywhere — fail-closed by encoding. This change opens each surface deliberately, with the window enforced: - Possession checks (schema directives, per-signal privilege filter) count scoped permissions as held; the new internal/scope package is the one place that reads them, always alongside their constraints. A constraint dq cannot interpret grants nothing. - Ranged queries — signals, events, segments, dailyActivity, and cloud-event reads (before/after) — REJECT requests whose range exceeds the window, naming the window in the error. Rejection rather than silent clamping: an aggregate computed over a narrower range than requested would be mislabeled as covering the full range. An absent range means all time and only an unbounded grant passes. - Latest-value queries (signalsLatest, signalsSnapshot) are evaluated WITHIN the window: a value recorded outside it is withheld, which is indistinguishable from the vehicle not having transmitted then, and the returned timestamps keep the result self-describing. The derived approximate location is gated separately from the raw coordinates row, since either qualifying permission may allow a timestamp the other does not. - All-time surfaces that cannot yet window their answers stay closed for scoped grants: availableSignals and dataSummary exclude signals behind scoped permissions (their counts and first/last-seen are unwindowable post hoc; the summary's top-level fold is recomputed from what survives), lastSeen is suppressed, and the gRPC fetch surface still reads only the flat claim. Teaching these surfaces window-bounded queries is follow-up work. NOTE: go.mod temporarily replaces github.com/DIMO-Network/dauth with a local path; swap for a tagged dauth release (DIMO-Network/dauth#17) before merge. Co-Authored-By: Claude Fable 5 --- go.mod | 2 + go.sum | 2 - internal/auth/directives.go | 15 ++- internal/auth/grpc.go | 9 +- internal/graph/arguments.go | 33 +++++- internal/graph/auth_helpers.go | 73 ++++++++++--- internal/graph/base.resolvers.go | 75 ++++++++++++-- internal/graph/base_resolvers_test.go | 5 +- internal/graph/events.resolvers.go | 5 + internal/graph/model/signalArgs.go | 10 ++ internal/graph/privilege_filter.go | 141 ++++++++++++++++++++++++-- internal/graph/scoped_windows_test.go | 125 +++++++++++++++++++++++ internal/graph/segments.resolvers.go | 8 ++ internal/repositories/signals.go | 31 ++++-- internal/scope/scope.go | 110 ++++++++++++++++++++ internal/scope/scope_test.go | 102 +++++++++++++++++++ 16 files changed, 692 insertions(+), 54 deletions(-) create mode 100644 internal/graph/scoped_windows_test.go create mode 100644 internal/scope/scope.go create mode 100644 internal/scope/scope_test.go diff --git a/go.mod b/go.mod index 949a30a..23c89be 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 55ba80b..56bd87d 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/auth/directives.go b/internal/auth/directives.go index 2035d68..cd63da6 100644 --- a/internal/auth/directives.go +++ b/internal/auth/directives.go @@ -4,9 +4,9 @@ import ( "context" "errors" "fmt" - "slices" "github.com/99designs/gqlgen/graphql" + "github.com/DIMO-Network/dq/internal/scope" ) const didArg = "subject" @@ -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) } } diff --git a/internal/auth/grpc.go b/internal/auth/grpc.go index 72be67c..27e5d5e 100644 --- a/internal/auth/grpc.go +++ b/internal/auth/grpc.go @@ -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 diff --git a/internal/graph/arguments.go b/internal/graph/arguments.go index c3ba8f3..9454e7a 100644 --- a/internal/graph/arguments.go +++ b/internal/graph/arguments.go @@ -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 @@ -26,12 +27,23 @@ 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 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) && !signalRangeAllowed(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) @@ -84,7 +96,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{ @@ -110,6 +122,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 } diff --git a/internal/graph/auth_helpers.go b/internal/graph/auth_helpers.go index 1f8449a..5239ccb 100644 --- a/internal/graph/auth_helpers.go +++ b/internal/graph/auth_helpers.go @@ -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 ( @@ -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 } @@ -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) { diff --git a/internal/graph/base.resolvers.go b/internal/graph/base.resolvers.go index 8530d9a..745b335 100644 --- a/internal/graph/base.resolvers.go +++ b/internal/graph/base.resolvers.go @@ -10,13 +10,15 @@ import ( "time" "github.com/99designs/gqlgen/graphql" + "github.com/DIMO-Network/dauth/pkg/tokenclaims" "github.com/DIMO-Network/dq/internal/graph/model" "github.com/DIMO-Network/dq/internal/repositories" + "github.com/DIMO-Network/dq/internal/scope" ) // Signals is the resolver for the signals field. func (r *queryResolver) Signals(ctx context.Context, subject string, interval string, from time.Time, to time.Time, filter *model.SignalFilter) ([]*model.SignalAggregations, error) { - aggArgs, err := aggregationArgsFromContext(ctx, subject, interval, from, to, filter) + aggArgs, err := aggregationArgsFromContext(ctx, r.SignalRepo, subject, interval, from, to, filter) if err != nil { return nil, err } @@ -25,7 +27,7 @@ func (r *queryResolver) Signals(ctx context.Context, subject string, interval st // SignalsLatest is the resolver for the signalsLatest field. func (r *queryResolver) SignalsLatest(ctx context.Context, subject string, filter *model.SignalFilter) (*model.SignalCollection, error) { - latestArgs, err := latestArgsFromContext(ctx, subject, filter) + latestArgs, err := latestArgsFromContext(ctx, r.SignalRepo, subject, filter) if err != nil { return nil, err } @@ -42,10 +44,15 @@ func (r *queryResolver) AvailableSignals(ctx context.Context, subject string, fi // lacking the privilege — gate the name list the same way signalsSnapshot gates values // (a VEHICLE_NON_LOCATION_DATA-only token would otherwise see currentLocationCoordinates // in the list, leaking that the vehicle has location data). - perms := permissionsFromCtx(ctx) + // + // Scoped permissions are excluded outright (not merely window-checked): + // listing a name asserts the vehicle has data for it at SOME time, which a + // windowed grant cannot confirm without a window-bounded existence query. + // Teaching this surface windows is a follow-up. + tok := tokenFromCtx(ctx) out := names[:0] for _, n := range names { - if hasPrivilegesForSignal(r.SignalRepo, n, perms) { + if hasUnscopedPrivilegesForSignal(r.SignalRepo, n, tok) { out = append(out, n) } } @@ -62,10 +69,14 @@ func (r *queryResolver) DataSummary(ctx context.Context, subject string, filter // seen of every signal, including location, without the privilege. Filter the name list // + per-signal summaries and recompute the aggregate count so it doesn't reveal the // location data-point count either. - perms := permissionsFromCtx(ctx) + // + // Summaries are all-time facts (counts, first/last seen) that cannot be + // window-checked post hoc, so signals behind scoped permissions are + // excluded entirely; a window-bounded summary query is a follow-up. + tok := tokenFromCtx(ctx) names := summary.AvailableSignals[:0] for _, n := range summary.AvailableSignals { - if hasPrivilegesForSignal(r.SignalRepo, n, perms) { + if hasUnscopedPrivilegesForSignal(r.SignalRepo, n, tok) { names = append(names, n) } } @@ -73,30 +84,76 @@ func (r *queryResolver) DataSummary(ctx context.Context, subject string, filter var total uint64 sds := summary.SignalDataSummary[:0] for _, s := range summary.SignalDataSummary { - if hasPrivilegesForSignal(r.SignalRepo, s.Name, perms) { + if hasUnscopedPrivilegesForSignal(r.SignalRepo, s.Name, tok) { sds = append(sds, s) total += s.NumberOfSignals } } summary.SignalDataSummary = sds summary.NumberOfSignals = total + if hasScopedPermissions(tok) { + // The event summaries and the top-level first/last-seen fold are also + // all-time facts; for scoped tokens recompute the fold from what + // survived and drop event summaries unless the event permissions are + // unconditional. + if !scope.Unscoped(tok, tokenclaims.PermissionGetNonLocationHistory) || + !scope.Unscoped(tok, tokenclaims.PermissionGetLocationHistory) { + summary.EventDataSummary = nil + } + summary.FirstSeen, summary.LastSeen = refoldSummaryRange(summary) + } return summary, nil } +// refoldSummaryRange recomputes the top-level first/last-seen range of a data +// summary from its surviving per-signal and per-event entries, so the fold +// cannot reveal timestamps of entries that were filtered out. +func refoldSummaryRange(summary *model.DataSummary) (time.Time, time.Time) { + minTime := time.Now().UTC() + maxTime := time.Date(1900, 1, 1, 0, 0, 0, 0, time.UTC) + for _, s := range summary.SignalDataSummary { + if s.FirstSeen.Before(minTime) { + minTime = s.FirstSeen + } + if s.LastSeen.After(maxTime) { + maxTime = s.LastSeen + } + } + for _, e := range summary.EventDataSummary { + if e.FirstSeen.Before(minTime) { + minTime = e.FirstSeen + } + if e.LastSeen.After(maxTime) { + maxTime = e.LastSeen + } + } + return minTime, maxTime +} + // SignalsSnapshot is the resolver for the signalsSnapshot field. func (r *queryResolver) SignalsSnapshot(ctx context.Context, subject string, filter *model.SignalFilter) (*model.SignalsSnapshotResponse, error) { resp, err := r.SignalRepo.GetSignalSnapshot(ctx, subject, filter) if err != nil { return nil, err } - permissions := permissionsFromCtx(ctx) + tok := tokenFromCtx(ctx) filtered := make([]*model.LatestSignal, 0, len(resp.Signals)) for _, sig := range resp.Signals { - if hasPrivilegesForSignal(r.SignalRepo, sig.Name, permissions) { + // Possession plus, for scoped permissions, the data window: a latest + // value recorded outside the window is withheld (point queries are + // window-evaluated, not rejected — the timestamps make the result + // self-describing). + if hasPrivilegesForSignal(r.SignalRepo, sig.Name, tok) && + signalValueVisible(r.SignalRepo, sig.Name, tok, sig.Timestamp) { filtered = append(filtered, sig) } } resp.Signals = filtered + if hasScopedPermissions(tok) { + // lastSeen spans every signal, windowed or not; suppressed for scoped + // tokens until it is window-aware. + resp.LastSeen = nil + } return resp, nil } diff --git a/internal/graph/base_resolvers_test.go b/internal/graph/base_resolvers_test.go index 12977dc..844a464 100644 --- a/internal/graph/base_resolvers_test.go +++ b/internal/graph/base_resolvers_test.go @@ -3,9 +3,9 @@ package graph import ( "testing" + "github.com/DIMO-Network/dauth/pkg/tokenclaims" "github.com/DIMO-Network/dq/internal/graph/model" "github.com/DIMO-Network/dq/internal/repositories" - "github.com/DIMO-Network/dauth/pkg/tokenclaims" ) func TestHasPrivilegesForSignal(t *testing.T) { @@ -100,7 +100,8 @@ func TestHasPrivilegesForSignal(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := hasPrivilegesForSignal(repo, tt.signal, tt.permissions) + tok := &tokenclaims.Token{CustomClaims: tokenclaims.CustomClaims{Permissions: tt.permissions}} + got := hasPrivilegesForSignal(repo, tt.signal, tok) if got != tt.want { t.Errorf("hasPrivilegesForSignal(%q, %v) = %v, want %v", tt.signal, tt.permissions, got, tt.want) diff --git a/internal/graph/events.resolvers.go b/internal/graph/events.resolvers.go index 1abf7db..bb4c7bd 100644 --- a/internal/graph/events.resolvers.go +++ b/internal/graph/events.resolvers.go @@ -14,5 +14,10 @@ import ( // Events is the resolver for the events field. func (r *queryResolver) Events(ctx context.Context, subject string, from time.Time, to time.Time, filter *model.EventFilter) ([]*model.Event, error) { + // Possession (both history permissions) is checked by the schema + // directive; this rejects ranges outside a scoped permission's window. + if err := eventsRangeAllowed(ctx, from, to); err != nil { + return nil, err + } return r.SignalRepo.GetEvents(ctx, subject, from, to, filter) } diff --git a/internal/graph/model/signalArgs.go b/internal/graph/model/signalArgs.go index d8b38aa..1cf84d7 100644 --- a/internal/graph/model/signalArgs.go +++ b/internal/graph/model/signalArgs.go @@ -30,6 +30,16 @@ type LatestSignalsArgs struct { LocationSignalNames map[string]struct{} // IncludeLastSeen is a flag to include a new signal for the last seen signal. IncludeLastSeen bool + // RowAllowed, when non-nil, is consulted for every fetched latest value; a + // value whose (name, timestamp) it rejects is omitted from the collection. + // Used to enforce scoped permissions' data windows: a latest value recorded + // outside the caller's window must not be shown. + RowAllowed func(name string, ts time.Time) bool + // ApproxLocationAllowed, when non-nil, gates the derived approximate + // location the same way. It is separate from RowAllowed because the + // approximate-location permission may allow a location row's timestamp + // that the raw-coordinates permission does not. + ApproxLocationAllowed func(ts time.Time) bool } // AggregatedSignalArgs is the arguments for querying aggregated signals. diff --git a/internal/graph/privilege_filter.go b/internal/graph/privilege_filter.go index 4a00b57..8ac64be 100644 --- a/internal/graph/privilege_filter.go +++ b/internal/graph/privilege_filter.go @@ -2,11 +2,13 @@ package graph import ( "context" - "slices" + "fmt" + "time" "github.com/DIMO-Network/dauth/pkg/tokenclaims" "github.com/DIMO-Network/dq/internal/graph/model" "github.com/DIMO-Network/dq/internal/repositories" + "github.com/DIMO-Network/dq/internal/scope" ) // privilegeEnumToPermission maps GraphQL Privilege enum values (as they appear @@ -21,12 +23,15 @@ var privilegeEnumToPermission = map[string]string{ "VEHICLE_RAW_DATA": tokenclaims.PermissionGetRawData, } -func hasPrivilegesForSignal(repo *repositories.Repository, name string, permissions []string) bool { - // currentLocationApproximateCoordinates is a derived signal not in the - // definitions file; either approximate or all-time location suffices. +// signalPermissionCheck evaluates one permission-level predicate for every +// permission a signal requires. It resolves the signal's required privileges +// (failing closed for unknown signals) and handles the derived +// approximate-coordinates signal, which is satisfiable by either the +// approximate- or all-time-location permission. +func signalPermissionCheck(repo *repositories.Repository, name string, tok *tokenclaims.Token, allowed func(*tokenclaims.Token, string) bool) bool { if name == model.ApproximateCoordinatesField { - return slices.Contains(permissions, tokenclaims.PermissionGetApproximateLocation) || - slices.Contains(permissions, tokenclaims.PermissionGetLocationHistory) + return allowed(tok, tokenclaims.PermissionGetApproximateLocation) || + allowed(tok, tokenclaims.PermissionGetLocationHistory) } required, ok := repo.RequiredPrivileges(name) if !ok { @@ -37,17 +42,131 @@ func hasPrivilegesForSignal(repo *repositories.Repository, name string, permissi if !mapped { return false } - if !slices.Contains(permissions, perm) { + if !allowed(tok, perm) { return false } } return true } -// permissionsFromCtx returns the caller's token permissions, or nil when unauthenticated. -func permissionsFromCtx(ctx context.Context) []string { - if tok, _ := ctx.Value(ClaimsContextKey{}).(*tokenclaims.Token); tok != nil { - return tok.Permissions +// hasPrivilegesForSignal reports whether the token holds every permission the +// named signal requires, scoped or not. Callers are responsible for enforcing +// scoped grants' data windows on whatever they return (range checks before +// querying, or per-value timestamp checks after). +func hasPrivilegesForSignal(repo *repositories.Repository, name string, tok *tokenclaims.Token) bool { + return signalPermissionCheck(repo, name, tok, scope.Holds) +} + +// hasUnscopedPrivilegesForSignal reports whether the token holds every +// permission the named signal requires unconditionally. Surfaces that expose +// all-time facts about a signal (counts, first/last seen) and cannot yet +// window them must gate on this. +func hasUnscopedPrivilegesForSignal(repo *repositories.Repository, name string, tok *tokenclaims.Token) bool { + return signalPermissionCheck(repo, name, tok, scope.Unscoped) +} + +// signalRangeAllowed reports whether the token may read the named signal over +// the half-open interval [from, to): every required permission must be held +// and its data window (if any) must contain the interval. +func signalRangeAllowed(repo *repositories.Repository, name string, tok *tokenclaims.Token, from, to time.Time) bool { + return signalPermissionCheck(repo, name, tok, func(tok *tokenclaims.Token, perm string) bool { + return scope.AllowsRange(tok, perm, from, to) + }) +} + +// signalAllowsAt reports whether the token may see a value of the named signal +// recorded at the instant t. +func signalAllowsAt(repo *repositories.Repository, name string, tok *tokenclaims.Token, t time.Time) bool { + return signalPermissionCheck(repo, name, tok, func(tok *tokenclaims.Token, perm string) bool { + return scope.AllowsAt(tok, perm, t) + }) +} + +// signalValueVisible reports whether a fetched value of the named signal, +// recorded at ts, may be shown. Unlike signalAllowsAt it does not re-check +// possession — that is the field directives' job on the latest path — it only +// vetoes values excluded by a held-but-scoped permission's window. +func signalValueVisible(repo *repositories.Repository, name string, tok *tokenclaims.Token, ts time.Time) bool { + if name == model.ApproximateCoordinatesField { + return approxLocationVisible(tok, ts) + } + required, ok := repo.RequiredPrivileges(name) + if !ok { + return false + } + for _, priv := range required { + perm, mapped := privilegeEnumToPermission[priv] + if !mapped { + return false + } + if scope.Holds(tok, perm) && !scope.AllowsAt(tok, perm, ts) { + return false + } + } + return true +} + +// approxLocationVisible reports whether a derived approximate-location value +// at ts may be shown: either qualifying permission must be held AND allow the +// timestamp. +func approxLocationVisible(tok *tokenclaims.Token, ts time.Time) bool { + return scope.AllowsAt(tok, tokenclaims.PermissionGetApproximateLocation, ts) || + scope.AllowsAt(tok, tokenclaims.PermissionGetLocationHistory, ts) +} + +// signalWindowDescription renders the data-window constraints relevant to the +// named signal for error messages; empty when none of its permissions are +// scoped. +func signalWindowDescription(repo *repositories.Repository, name string, tok *tokenclaims.Token) string { + var perms []string + if name == model.ApproximateCoordinatesField { + perms = []string{tokenclaims.PermissionGetApproximateLocation, tokenclaims.PermissionGetLocationHistory} + } else if required, ok := repo.RequiredPrivileges(name); ok { + for _, priv := range required { + if perm, mapped := privilegeEnumToPermission[priv]; mapped { + perms = append(perms, perm) + } + } + } + for _, perm := range perms { + if desc := scope.Describe(tok, perm); desc != "" { + return desc + } + } + return "" +} + +// eventsRangeAllowed rejects a query over [from, to) when either history +// permission (the pair required by the events, segments, and dailyActivity +// queries) is scoped to a data window that does not contain the range. +// Possession itself is the schema directives' job. +func eventsRangeAllowed(ctx context.Context, from, to time.Time) error { + tok := tokenFromCtx(ctx) + if !hasScopedPermissions(tok) { + return nil + } + for _, perm := range []string{tokenclaims.PermissionGetNonLocationHistory, tokenclaims.PermissionGetLocationHistory} { + if scope.Holds(tok, perm) && !scope.AllowsRange(tok, perm, from, to) { + if desc := scope.Describe(tok, perm); desc != "" { + return fmt.Errorf("unauthorized: requested range is outside the token's data window: %s is %s", perm, desc) + } + return fmt.Errorf("unauthorized: token does not allow %s over the requested range", perm) + } } return nil } + +// tokenFromCtx returns the caller's permission token, or nil when +// unauthenticated. +func tokenFromCtx(ctx context.Context) *tokenclaims.Token { + tok, _ := ctx.Value(ClaimsContextKey{}).(*tokenclaims.Token) + return tok +} + +// hasScopedPermissions reports whether any permission on the token is granted +// under constraints. Surfaces that expose cross-signal facts (e.g. the +// lastSeen timestamp, computed over all signals) suppress them for such +// tokens until they learn to window them. +func hasScopedPermissions(tok *tokenclaims.Token) bool { + return tok != nil && len(tok.ScopedPermissions) > 0 +} diff --git a/internal/graph/scoped_windows_test.go b/internal/graph/scoped_windows_test.go new file mode 100644 index 0000000..c392133 --- /dev/null +++ b/internal/graph/scoped_windows_test.go @@ -0,0 +1,125 @@ +package graph + +import ( + "context" + "testing" + "time" + + "github.com/DIMO-Network/dauth/pkg/tokenclaims" + "github.com/DIMO-Network/dq/internal/graph/model" + "github.com/DIMO-Network/dq/internal/repositories" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mustTS(t *testing.T, s string) time.Time { + t.Helper() + ts, err := time.Parse(time.RFC3339, s) + require.NoError(t, err) + return ts +} + +func testRepo(t *testing.T) *repositories.Repository { + t.Helper() + repo, err := repositories.NewRepository(nil) + require.NoError(t, err) + return repo +} + +// q2WindowedToken holds non-location history unconditionally and location +// history only for Q2 2026. +func q2WindowedToken() *tokenclaims.Token { + return &tokenclaims.Token{ + CustomClaims: tokenclaims.CustomClaims{ + Asset: "did:erc721:137:0xbA5738a18d83D41847dfFbDC6101d37C69c9B0cF:42", + Permissions: []string{tokenclaims.PermissionGetNonLocationHistory}, + ScopedPermissions: []tokenclaims.ScopedPermission{{ + Name: tokenclaims.PermissionGetLocationHistory, + Constraint: []tokenclaims.Constraint{ + {LeftOperand: tokenclaims.LeftOperandRecordedAt, Operator: tokenclaims.OperatorGteq, RightOperand: "2026-04-01T00:00:00Z"}, + {LeftOperand: tokenclaims.LeftOperandRecordedAt, Operator: tokenclaims.OperatorLt, RightOperand: "2026-07-01T00:00:00Z"}, + }, + }}, + }, + } +} + +func TestSignalRangeAllowed_Windowed(t *testing.T) { + repo := testRepo(t) + tok := q2WindowedToken() + + inQ2From, inQ2To := mustTS(t, "2026-05-01T00:00:00Z"), mustTS(t, "2026-06-01T00:00:00Z") + allTimeFrom, allTimeTo := mustTS(t, "2020-01-01T00:00:00Z"), mustTS(t, "2027-01-01T00:00:00Z") + + // The unconditional permission covers any range. + assert.True(t, signalRangeAllowed(repo, "speed", tok, allTimeFrom, allTimeTo)) + // The windowed location permission covers only in-window ranges. + assert.True(t, signalRangeAllowed(repo, "currentLocationCoordinates", tok, inQ2From, inQ2To)) + assert.False(t, signalRangeAllowed(repo, "currentLocationCoordinates", tok, allTimeFrom, allTimeTo)) + // The derived approximate signal follows the location window here (no + // approximate permission on the token). + assert.True(t, signalRangeAllowed(repo, model.ApproximateCoordinatesField, tok, inQ2From, inQ2To)) + assert.False(t, signalRangeAllowed(repo, model.ApproximateCoordinatesField, tok, allTimeFrom, allTimeTo)) +} + +func TestSignalValueVisible_Windowed(t *testing.T) { + repo := testRepo(t) + tok := q2WindowedToken() + + inWindow := mustTS(t, "2026-05-15T12:00:00Z") + outOfWindow := mustTS(t, "2026-03-15T12:00:00Z") + + // Values of the unconditional signal are visible at any timestamp. + assert.True(t, signalValueVisible(repo, "speed", tok, outOfWindow)) + // Values of the windowed signal are visible only inside the window. + assert.True(t, signalValueVisible(repo, "currentLocationCoordinates", tok, inWindow)) + assert.False(t, signalValueVisible(repo, "currentLocationCoordinates", tok, outOfWindow)) +} + +func TestHasUnscopedPrivilegesForSignal(t *testing.T) { + repo := testRepo(t) + tok := q2WindowedToken() + + // All-time surfaces (availableSignals, dataSummary) serve the + // unconditional permission's signals but exclude the windowed one. + assert.True(t, hasUnscopedPrivilegesForSignal(repo, "speed", tok)) + assert.False(t, hasUnscopedPrivilegesForSignal(repo, "currentLocationCoordinates", tok)) + // Possession still recognizes both. + assert.True(t, hasPrivilegesForSignal(repo, "currentLocationCoordinates", tok)) +} + +func TestRequireRawDataToken_Windowed(t *testing.T) { + // Location scoped to Q2, non-location unconditional: raw-data access is + // via the history pair, so the location window governs. + tok := q2WindowedToken() + ctx := context.WithValue(context.Background(), ClaimsContextKey{}, tok) + + after, before := mustTS(t, "2026-05-01T00:00:00Z"), mustTS(t, "2026-06-01T00:00:00Z") + outAfter := mustTS(t, "2026-01-01T00:00:00Z") + + // Bounded inside the window: allowed. + _, err := requireRawDataToken(ctx, &model.CloudEventFilter{After: &after, Before: &before}) + assert.NoError(t, err) + // Bounds exceeding the window: rejected. + _, err = requireRawDataToken(ctx, &model.CloudEventFilter{After: &outAfter, Before: &before}) + assert.Error(t, err) + // No bounds at all means "all time": rejected for a windowed grant. + _, err = requireRawDataToken(ctx, nil) + assert.Error(t, err) + + // An unconditional raw-data grant is unaffected by filters. + rawTok := &tokenclaims.Token{CustomClaims: tokenclaims.CustomClaims{ + Permissions: []string{tokenclaims.PermissionGetRawData}, + }} + rawCtx := context.WithValue(context.Background(), ClaimsContextKey{}, rawTok) + _, err = requireRawDataToken(rawCtx, nil) + assert.NoError(t, err) + + // A token with neither path is rejected outright. + noneTok := &tokenclaims.Token{CustomClaims: tokenclaims.CustomClaims{ + Permissions: []string{tokenclaims.PermissionGetVINCredential}, + }} + noneCtx := context.WithValue(context.Background(), ClaimsContextKey{}, noneTok) + _, err = requireRawDataToken(noneCtx, &model.CloudEventFilter{After: &after, Before: &before}) + assert.Error(t, err) +} diff --git a/internal/graph/segments.resolvers.go b/internal/graph/segments.resolvers.go index 8739fdc..f1309dd 100644 --- a/internal/graph/segments.resolvers.go +++ b/internal/graph/segments.resolvers.go @@ -14,10 +14,18 @@ import ( // Segments is the resolver for the segments field. func (r *queryResolver) Segments(ctx context.Context, subject string, from time.Time, to time.Time, mechanism model.DetectionMechanism, config *model.SegmentConfig, signalRequests []*model.SegmentSignalRequest, eventRequests []*model.SegmentEventRequest, limit *int, after *time.Time) ([]*model.Segment, error) { + // Segments require the same history-permission pair as events; reject + // ranges outside a scoped permission's data window. + if err := eventsRangeAllowed(ctx, from, to); err != nil { + return nil, err + } return r.SignalRepo.GetSegments(ctx, subject, from, to, mechanism, config, signalRequests, eventRequests, limit, after) } // DailyActivity is the resolver for the dailyActivity field. func (r *queryResolver) DailyActivity(ctx context.Context, subject string, from time.Time, to time.Time, mechanism model.DetectionMechanism, config *model.SegmentConfig, signalRequests []*model.SegmentSignalRequest, eventRequests []*model.SegmentEventRequest, timezone *string) ([]*model.DailyActivity, error) { + if err := eventsRangeAllowed(ctx, from, to); err != nil { + return nil, err + } return r.SignalRepo.GetDailyActivity(ctx, subject, from, to, mechanism, config, signalRequests, eventRequests, timezone) } diff --git a/internal/repositories/signals.go b/internal/repositories/signals.go index c47d58d..4c5fb12 100644 --- a/internal/repositories/signals.go +++ b/internal/repositories/signals.go @@ -147,14 +147,27 @@ func (r *Repository) GetSignalLatest(ctx context.Context, latestArgs *model.Late return nil, handleDBError(ctx, err) } coll := &model.SignalCollection{} + var rawLocation *vss.Signal for _, signal := range signals { if signal.Data.Name == model.LastSeenField && !signal.Data.Timestamp.Equal(unixEpoch) { coll.LastSeen = &signal.Data.Timestamp continue } + // The raw location row also feeds the derived approximate location, + // which is gated separately: a caller may be allowed the approximate + // value at a timestamp the raw coordinates are not. + if signal.Data.Name == vss.FieldCurrentLocationCoordinates { + rawLocation = signal + } + if latestArgs.RowAllowed != nil && !latestArgs.RowAllowed(signal.Data.Name, signal.Data.Timestamp) { + continue + } model.SetCollectionField(coll, signal) } - setApproximateLocationInCollection(coll) + if rawLocation != nil && + (latestArgs.ApproxLocationAllowed == nil || latestArgs.ApproxLocationAllowed(rawLocation.Data.Timestamp)) { + setApproximateLocationFromSignal(coll, rawLocation) + } return coll, nil } @@ -353,21 +366,25 @@ func GetApproximateLoc(lat, long float64) *h3.LatLng { return &latLong } -func setApproximateLocationInCollection(coll *model.SignalCollection) { - if coll == nil || coll.CurrentLocationCoordinates == nil { +// setApproximateLocationFromSignal derives the approximate current location +// from the raw location signal row. It works from the row rather than the +// assembled collection field so the raw coordinates can be withheld (e.g. by a +// data-window check) while the approximate value is still served. +func setApproximateLocationFromSignal(coll *model.SignalCollection, signal *vss.Signal) { + if coll == nil || signal == nil { return } - loc := coll.CurrentLocationCoordinates - latLong := GetApproximateLoc(loc.Value.Latitude, loc.Value.Longitude) + loc := signal.Data.ValueLocation + latLong := GetApproximateLoc(loc.Latitude, loc.Longitude) if latLong == nil { return } coll.CurrentLocationApproximateCoordinates = &model.SignalLocation{ - Timestamp: loc.Timestamp, + Timestamp: signal.Data.Timestamp, Value: &model.Location{ Latitude: latLong.Lat, Longitude: latLong.Lng, - Hdop: loc.Value.Hdop, + Hdop: loc.HDOP, }, } } diff --git a/internal/scope/scope.go b/internal/scope/scope.go new file mode 100644 index 0000000..dd6eb73 --- /dev/null +++ b/internal/scope/scope.go @@ -0,0 +1,110 @@ +// Package scope answers permission questions against a dauth permission +// token, including permissions granted under constraints (the +// scoped_permissions claim). +// +// The claim encoding is fail-closed by construction: a permission granted +// under constraints appears ONLY in scoped_permissions, never in the flat +// permissions array, so code that reads only the flat claim refuses scoped +// grants outright. This package is the one place that deliberately opens +// scoped grants back up — and only alongside their constraints. Every helper +// here treats a constraint it cannot interpret as an absent grant. +package scope + +import ( + "fmt" + "time" + + "github.com/DIMO-Network/dauth/pkg/tokenclaims" +) + +// constraintsFor returns the constraint atoms under which tok holds the named +// permission: (nil, true) for an unconditional grant, (atoms, true) for a +// scoped one, (nil, false) when the permission is not held at all. +func constraintsFor(tok *tokenclaims.Token, name string) ([]tokenclaims.Constraint, bool) { + if tok == nil { + return nil, false + } + for _, p := range tok.Permissions { + if p == name { + return nil, true + } + } + for _, sp := range tok.ScopedPermissions { + if sp.Name == name { + return sp.Constraint, true + } + } + return nil, false +} + +// Holds reports whether tok holds the named permission at all, conditionally +// or not. Use it for possession checks whose data access is separately +// window-checked; a surface with no window enforcement must use Unscoped +// instead. +func Holds(tok *tokenclaims.Token, name string) bool { + _, ok := constraintsFor(tok, name) + return ok +} + +// Unscoped reports whether tok holds the named permission unconditionally. +// Surfaces that cannot enforce constraints must gate on this. +func Unscoped(tok *tokenclaims.Token, name string) bool { + cs, ok := constraintsFor(tok, name) + return ok && len(cs) == 0 +} + +// AllowsRange reports whether tok holds the named permission for every +// instant in the half-open interval [from, to). Unconditional grants allow +// any range; a grant whose constraints cannot be interpreted allows nothing. +func AllowsRange(tok *tokenclaims.Token, name string, from, to time.Time) bool { + cs, ok := constraintsFor(tok, name) + if !ok { + return false + } + allowed, err := tokenclaims.AllowsInterval(cs, from, to) + return err == nil && allowed +} + +// AllowsAt reports whether tok holds the named permission for data recorded +// at the instant t. +func AllowsAt(tok *tokenclaims.Token, name string, t time.Time) bool { + cs, ok := constraintsFor(tok, name) + if !ok { + return false + } + allowed, err := tokenclaims.AllowsAt(cs, t) + return err == nil && allowed +} + +// Describe renders the constraints under which tok holds the named permission, +// for error messages ("granted for 2026-04-01T00:00:00Z <= recordedAt < ..."). +// Empty for permissions held unconditionally or not at all. +func Describe(tok *tokenclaims.Token, name string) string { + cs, ok := constraintsFor(tok, name) + if !ok || len(cs) == 0 { + return "" + } + lower, upper, err := tokenclaims.RecordedAtWindow(cs) + if err != nil { + return "granted under constraints this service cannot interpret" + } + s := "granted for data recorded" + if lower != nil { + op := ">" + if lower.Inclusive { + op = ">=" + } + s += fmt.Sprintf(" %s %s", op, lower.Time.Format(time.RFC3339)) + } + if lower != nil && upper != nil { + s += " and" + } + if upper != nil { + op := "<" + if upper.Inclusive { + op = "<=" + } + s += fmt.Sprintf(" %s %s", op, upper.Time.Format(time.RFC3339)) + } + return s +} diff --git a/internal/scope/scope_test.go b/internal/scope/scope_test.go new file mode 100644 index 0000000..36a0bb1 --- /dev/null +++ b/internal/scope/scope_test.go @@ -0,0 +1,102 @@ +package scope + +import ( + "testing" + "time" + + "github.com/DIMO-Network/dauth/pkg/tokenclaims" + "github.com/stretchr/testify/assert" +) + +func ts(s string) time.Time { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + panic(err) + } + return t +} + +func windowedToken() *tokenclaims.Token { + return &tokenclaims.Token{ + CustomClaims: tokenclaims.CustomClaims{ + Permissions: []string{tokenclaims.PermissionGetNonLocationHistory}, + ScopedPermissions: []tokenclaims.ScopedPermission{{ + Name: tokenclaims.PermissionGetLocationHistory, + Constraint: []tokenclaims.Constraint{ + {LeftOperand: tokenclaims.LeftOperandRecordedAt, Operator: tokenclaims.OperatorGteq, RightOperand: "2026-04-01T00:00:00Z"}, + {LeftOperand: tokenclaims.LeftOperandRecordedAt, Operator: tokenclaims.OperatorLt, RightOperand: "2026-07-01T00:00:00Z"}, + }, + }}, + }, + } +} + +func TestHoldsAndUnscoped(t *testing.T) { + tok := windowedToken() + + assert.True(t, Holds(tok, tokenclaims.PermissionGetNonLocationHistory)) + assert.True(t, Holds(tok, tokenclaims.PermissionGetLocationHistory)) + assert.False(t, Holds(tok, tokenclaims.PermissionGetRawData)) + + assert.True(t, Unscoped(tok, tokenclaims.PermissionGetNonLocationHistory)) + assert.False(t, Unscoped(tok, tokenclaims.PermissionGetLocationHistory)) + assert.False(t, Unscoped(tok, tokenclaims.PermissionGetRawData)) + + assert.False(t, Holds(nil, tokenclaims.PermissionGetNonLocationHistory)) +} + +func TestAllowsRange(t *testing.T) { + tok := windowedToken() + loc := tokenclaims.PermissionGetLocationHistory + nonLoc := tokenclaims.PermissionGetNonLocationHistory + + // Unconditional grants allow any range. + assert.True(t, AllowsRange(tok, nonLoc, ts("1970-01-01T00:00:00Z"), ts("2100-01-01T00:00:00Z"))) + // Scoped grants allow ranges inside the window... + assert.True(t, AllowsRange(tok, loc, ts("2026-05-01T00:00:00Z"), ts("2026-06-01T00:00:00Z"))) + // ...and reject ranges that exceed it on either side. + assert.False(t, AllowsRange(tok, loc, ts("2026-03-01T00:00:00Z"), ts("2026-06-01T00:00:00Z"))) + assert.False(t, AllowsRange(tok, loc, ts("2026-05-01T00:00:00Z"), ts("2026-08-01T00:00:00Z"))) + // Unheld permissions allow nothing. + assert.False(t, AllowsRange(tok, tokenclaims.PermissionGetRawData, ts("2026-05-01T00:00:00Z"), ts("2026-06-01T00:00:00Z"))) +} + +func TestAllowsAt(t *testing.T) { + tok := windowedToken() + loc := tokenclaims.PermissionGetLocationHistory + + assert.True(t, AllowsAt(tok, loc, ts("2026-05-15T12:00:00Z"))) + assert.False(t, AllowsAt(tok, loc, ts("2026-03-15T12:00:00Z"))) + assert.False(t, AllowsAt(tok, loc, ts("2026-07-01T00:00:00Z"))) // lt bound is exclusive + assert.True(t, AllowsAt(tok, tokenclaims.PermissionGetNonLocationHistory, ts("1999-01-01T00:00:00Z"))) +} + +func TestUninterpretableConstraintsGrantNothing(t *testing.T) { + tok := &tokenclaims.Token{ + CustomClaims: tokenclaims.CustomClaims{ + ScopedPermissions: []tokenclaims.ScopedPermission{{ + Name: tokenclaims.PermissionGetLocationHistory, + Constraint: []tokenclaims.Constraint{ + {LeftOperand: "dimo:geofence", Operator: "within", RightOperand: "POLYGON(...)"}, + }, + }}, + }, + } + loc := tokenclaims.PermissionGetLocationHistory + + // Held for possession purposes... + assert.True(t, Holds(tok, loc)) + // ...but no data access: the constraint is not understood, so fail closed. + assert.False(t, AllowsRange(tok, loc, ts("2026-05-01T00:00:00Z"), ts("2026-06-01T00:00:00Z"))) + assert.False(t, AllowsAt(tok, loc, ts("2026-05-15T12:00:00Z"))) + assert.Equal(t, "granted under constraints this service cannot interpret", Describe(tok, loc)) +} + +func TestDescribe(t *testing.T) { + tok := windowedToken() + assert.Equal(t, + "granted for data recorded >= 2026-04-01T00:00:00Z and < 2026-07-01T00:00:00Z", + Describe(tok, tokenclaims.PermissionGetLocationHistory)) + assert.Empty(t, Describe(tok, tokenclaims.PermissionGetNonLocationHistory)) + assert.Empty(t, Describe(tok, tokenclaims.PermissionGetRawData)) +} From fab73673dacc3f410118f8e3bb9462306203ffa9 Mon Sep 17 00:00:00 2001 From: Dylan Moreland Date: Fri, 17 Jul 2026 15:43:00 -0400 Subject: [PATCH 2/2] Range-check only held permissions; live GraphQL window tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: the signals range check rejected the WHOLE query when a scoped token requested a signal whose permission it does not hold at all — where the design (and main's behavior) leaves possession to the per-field privilege directives. A scoped token mixing one windowed signal with one unheld signal got an opaque window rejection instead of the directive's per-field missing-privilege error. The range check now speaks only for permissions the token actually holds, mirroring the latest path's signalValueVisible split. Also adds tests/scoped_windows_graphql_test.go: end-to-end tests over the REAL executable schema with the REAL auth directives (the existing harnesses stub auth) and a real DuckLake-backed repository, with claims JSON-round-tripped exactly as they ride in a dauth-minted JWT. Covers: in-window ranged queries served, out-of-window ranges rejected naming the window, unconditional signals unaffected, latest served in-window / withheld out-of-window, lastSeen suppression, approximate location served from a raw row the location window withholds, availableSignals/ dataSummary exclusion + count recomputation + event-summary drop, events range rejection, and the unheld-signal-is-a-field-error fix. Co-Authored-By: Claude Fable 5 --- internal/graph/arguments.go | 9 +- internal/graph/privilege_filter.go | 36 ++- internal/graph/scoped_windows_test.go | 30 ++- tests/scoped_windows_graphql_test.go | 363 ++++++++++++++++++++++++++ 4 files changed, 419 insertions(+), 19 deletions(-) create mode 100644 tests/scoped_windows_graphql_test.go diff --git a/internal/graph/arguments.go b/internal/graph/arguments.go index 9454e7a..b1c12e4 100644 --- a/internal/graph/arguments.go +++ b/internal/graph/arguments.go @@ -35,10 +35,11 @@ func aggregationArgsFromContext(ctx context.Context, repo *repositories.Reposito continue } // Possession is checked by the field's privilege directive; this rejects - // requested ranges outside a 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) && !signalRangeAllowed(repo, field.Name, tok, from, to) { + // 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) } diff --git a/internal/graph/privilege_filter.go b/internal/graph/privilege_filter.go index 8ac64be..411759b 100644 --- a/internal/graph/privilege_filter.go +++ b/internal/graph/privilege_filter.go @@ -65,13 +65,35 @@ func hasUnscopedPrivilegesForSignal(repo *repositories.Repository, name string, return signalPermissionCheck(repo, name, tok, scope.Unscoped) } -// signalRangeAllowed reports whether the token may read the named signal over -// the half-open interval [from, to): every required permission must be held -// and its data window (if any) must contain the interval. -func signalRangeAllowed(repo *repositories.Repository, name string, tok *tokenclaims.Token, from, to time.Time) bool { - return signalPermissionCheck(repo, name, tok, func(tok *tokenclaims.Token, perm string) bool { - return scope.AllowsRange(tok, perm, from, to) - }) +// signalRangeWithinWindows reports whether no HELD permission's data window +// excludes any part of [from, to) for the named signal. Like +// signalValueVisible it does not check possession — an unheld permission is +// the field directives' problem — it only vetoes ranges that a +// held-but-scoped permission's window does not contain. +func signalRangeWithinWindows(repo *repositories.Repository, name string, tok *tokenclaims.Token, from, to time.Time) bool { + if name == model.ApproximateCoordinatesField { + heldApprox := scope.Holds(tok, tokenclaims.PermissionGetApproximateLocation) + heldLoc := scope.Holds(tok, tokenclaims.PermissionGetLocationHistory) + if !heldApprox && !heldLoc { + return true + } + return scope.AllowsRange(tok, tokenclaims.PermissionGetApproximateLocation, from, to) || + scope.AllowsRange(tok, tokenclaims.PermissionGetLocationHistory, from, to) + } + required, ok := repo.RequiredPrivileges(name) + if !ok { + return true + } + for _, priv := range required { + perm, mapped := privilegeEnumToPermission[priv] + if !mapped { + continue + } + if scope.Holds(tok, perm) && !scope.AllowsRange(tok, perm, from, to) { + return false + } + } + return true } // signalAllowsAt reports whether the token may see a value of the named signal diff --git a/internal/graph/scoped_windows_test.go b/internal/graph/scoped_windows_test.go index c392133..80c52a3 100644 --- a/internal/graph/scoped_windows_test.go +++ b/internal/graph/scoped_windows_test.go @@ -44,22 +44,36 @@ func q2WindowedToken() *tokenclaims.Token { } } -func TestSignalRangeAllowed_Windowed(t *testing.T) { +func TestSignalRangeWithinWindows(t *testing.T) { repo := testRepo(t) tok := q2WindowedToken() inQ2From, inQ2To := mustTS(t, "2026-05-01T00:00:00Z"), mustTS(t, "2026-06-01T00:00:00Z") allTimeFrom, allTimeTo := mustTS(t, "2020-01-01T00:00:00Z"), mustTS(t, "2027-01-01T00:00:00Z") - // The unconditional permission covers any range. - assert.True(t, signalRangeAllowed(repo, "speed", tok, allTimeFrom, allTimeTo)) - // The windowed location permission covers only in-window ranges. - assert.True(t, signalRangeAllowed(repo, "currentLocationCoordinates", tok, inQ2From, inQ2To)) - assert.False(t, signalRangeAllowed(repo, "currentLocationCoordinates", tok, allTimeFrom, allTimeTo)) + // The unconditional permission imposes no window. + assert.True(t, signalRangeWithinWindows(repo, "speed", tok, allTimeFrom, allTimeTo)) + // The windowed location permission admits only in-window ranges. + assert.True(t, signalRangeWithinWindows(repo, "currentLocationCoordinates", tok, inQ2From, inQ2To)) + assert.False(t, signalRangeWithinWindows(repo, "currentLocationCoordinates", tok, allTimeFrom, allTimeTo)) // The derived approximate signal follows the location window here (no // approximate permission on the token). - assert.True(t, signalRangeAllowed(repo, model.ApproximateCoordinatesField, tok, inQ2From, inQ2To)) - assert.False(t, signalRangeAllowed(repo, model.ApproximateCoordinatesField, tok, allTimeFrom, allTimeTo)) + assert.True(t, signalRangeWithinWindows(repo, model.ApproximateCoordinatesField, tok, inQ2From, inQ2To)) + assert.False(t, signalRangeWithinWindows(repo, model.ApproximateCoordinatesField, tok, allTimeFrom, allTimeTo)) + + // Possession is NOT this check's job: a signal whose permission the token + // does not hold at all passes through, to be denied per-field by the + // privilege directives rather than rejecting the whole query. + vinOnly := &tokenclaims.Token{CustomClaims: tokenclaims.CustomClaims{ + ScopedPermissions: []tokenclaims.ScopedPermission{{ + Name: tokenclaims.PermissionGetVINCredential, + Constraint: []tokenclaims.Constraint{ + {LeftOperand: tokenclaims.LeftOperandRecordedAt, Operator: tokenclaims.OperatorGteq, RightOperand: "2026-04-01T00:00:00Z"}, + }, + }}, + }} + assert.True(t, signalRangeWithinWindows(repo, "speed", vinOnly, allTimeFrom, allTimeTo)) + assert.True(t, signalRangeWithinWindows(repo, model.ApproximateCoordinatesField, vinOnly, allTimeFrom, allTimeTo)) } func TestSignalValueVisible_Windowed(t *testing.T) { diff --git a/tests/scoped_windows_graphql_test.go b/tests/scoped_windows_graphql_test.go new file mode 100644 index 0000000..7acca13 --- /dev/null +++ b/tests/scoped_windows_graphql_test.go @@ -0,0 +1,363 @@ +// scoped_windows_graphql_test.go drives the REAL GraphQL surface — real auth +// directives, real resolvers, real DuckLake-backed repository — with tokens +// carrying scoped permissions (ODRL data windows), verifying the enforcement +// model end to end: ranged queries reject ranges outside a held window, latest +// paths are evaluated within it, and all-time surfaces exclude scoped signals. +// +// The claims are round-tripped through JSON exactly as they ride in the JWT +// payload, so these tests also pin wire compatibility with dauth's minting. +package tests + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/99designs/gqlgen/client" + gqlhandler "github.com/99designs/gqlgen/graphql/handler" + "github.com/99designs/gqlgen/graphql/handler/transport" + "github.com/DIMO-Network/dauth/pkg/tokenclaims" + "github.com/DIMO-Network/dq/internal/auth" + "github.com/DIMO-Network/dq/internal/graph" + "github.com/DIMO-Network/dq/internal/materializer" + "github.com/DIMO-Network/dq/internal/repositories" + "github.com/DIMO-Network/dq/internal/service/duck" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func locationAt(ts time.Time, lat, lon float64) map[string]any { + return map[string]any{ + "name": "currentLocationCoordinates", + "timestamp": ts.Format(time.RFC3339Nano), + "value": map[string]any{"latitude": lat, "longitude": lon}, + } +} + +// newScopedGraphQLClient builds a GraphQL client over the real executable +// schema with the REAL auth directives (unlike the passDirective harnesses), +// injecting the given token's claims into every request context the same way +// auth.AddClaimHandler does — after a JSON round trip, so the claims are +// exactly what a dauth-minted JWT payload would parse into. +func newScopedGraphQLClient(t *testing.T, svc *duck.Service, tok *tokenclaims.Token) *client.Client { + t.Helper() + repo, err := repositories.NewRepository(repositories.ComposeBackend(duck.NewLakeQueries(svc), nil)) + require.NoError(t, err) + + cfg := graph.Config{Resolvers: &graph.Resolver{SignalRepo: repo}} + cfg.Directives.RequiresVehicleToken = auth.NewVehicleTokenCheck() + cfg.Directives.RequiresAllOfPrivileges = auth.AllOfPrivilegeCheck + cfg.Directives.RequiresOneOfPrivilege = auth.OneOfPrivilegeCheck + cfg.Directives.IsSignal = passDirective + cfg.Directives.HasAggregation = passDirective + cfg.Directives.McpHide = passDirective + + srv := gqlhandler.New(graph.NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + + payload, err := json.Marshal(tok) + require.NoError(t, err) + var claim auth.DQClaim + require.NoError(t, json.Unmarshal(payload, &claim)) + if len(tok.ScopedPermissions) > 0 { + require.NotEmpty(t, claim.ScopedPermissions, "scoped_permissions lost in JSON round trip") + } + + wrapped := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), auth.DQClaimContextKey{}, &claim) + ctx = context.WithValue(ctx, graph.ClaimsContextKey{}, &claim.Token) + srv.ServeHTTP(w, r.WithContext(ctx)) + }) + return client.New(wrapped) +} + +// scopedFixture seeds speed + location at two instants — outTS (72h ago) and +// inTS (24h ago) — and materializes them into the lake. +func scopedFixture(t *testing.T) (svc *duck.Service, subject string, base, outTS, inTS time.Time) { + t.Helper() + ctx := context.Background() + svc = newLakeService(t, t.TempDir()) + db := svc.DB() + subject = fmt.Sprintf("did:erc721:137:%s:42", vehicleNFT.Hex()) + base = time.Now().UTC().Truncate(time.Hour) + outTS = base.Add(-72 * time.Hour) + inTS = base.Add(-24 * time.Hour) + + seedRawStatus(t, db, "sw-1", subject, outTS, speedAt(outTS, 30), locationAt(outTS, 40.0, -70.0)) + seedRawStatus(t, db, "sw-2", subject, inTS, speedAt(inTS, 70), locationAt(inTS, 41.0, -71.0)) + + mat, err := materializer.NewDuckLakeMaterializer(ctx, db, zerolog.Nop()) + require.NoError(t, err) + runner := materializer.New(materializer.Config{ChainID: 137, VehicleNFTAddress: vehicleNFT}, zerolog.Nop()). + WithDuckLake(mat) + require.Positive(t, drainRunner(t, ctx, runner)) + return svc, subject, base, outTS, inTS +} + +func recordedAtWindow(from, to time.Time) []tokenclaims.Constraint { + return []tokenclaims.Constraint{ + {LeftOperand: tokenclaims.LeftOperandRecordedAt, Operator: tokenclaims.OperatorGteq, RightOperand: from.Format(time.RFC3339)}, + {LeftOperand: tokenclaims.LeftOperandRecordedAt, Operator: tokenclaims.OperatorLt, RightOperand: to.Format(time.RFC3339)}, + } +} + +// scopedLocToken holds non-location history unconditionally and location +// history only for [winFrom, winTo). +func scopedLocToken(subject string, winFrom, winTo time.Time) *tokenclaims.Token { + return &tokenclaims.Token{CustomClaims: tokenclaims.CustomClaims{ + Asset: subject, + Permissions: []string{tokenclaims.PermissionGetNonLocationHistory}, + ScopedPermissions: []tokenclaims.ScopedPermission{{ + Name: tokenclaims.PermissionGetLocationHistory, + Constraint: recordedAtWindow(winFrom, winTo), + }}, + }} +} + +func TestScopedWindowsGraphQL_RangedSignals(t *testing.T) { + svc, subject, base, _, _ := scopedFixture(t) + // Window covers inTS (24h ago) but not outTS (72h ago). + c := newScopedGraphQLClient(t, svc, scopedLocToken(subject, base.Add(-48*time.Hour), base.Add(time.Hour))) + + signalsQuery := func(from, to time.Time, fields string) string { + return fmt.Sprintf(`query { signals(subject:%q, interval:"1h", from:%q, to:%q) { timestamp %s } }`, + subject, from.Format(time.RFC3339), to.Format(time.RFC3339), fields) + } + + t.Run("in-window range serves both signals", func(t *testing.T) { + var resp struct { + Signals []struct { + Timestamp string + Speed *float64 + CurrentLocationCoordinates *struct{ Latitude float64 } + } + } + err := c.Post(signalsQuery(base.Add(-40*time.Hour), base.Add(-12*time.Hour), + `speed(agg: MAX) currentLocationCoordinates(agg: LAST) { latitude }`), &resp) + require.NoError(t, err) + var gotSpeed, gotLoc bool + for _, row := range resp.Signals { + if row.Speed != nil && *row.Speed == 70 { + gotSpeed = true + } + if row.CurrentLocationCoordinates != nil && row.CurrentLocationCoordinates.Latitude == 41.0 { + gotLoc = true + } + } + assert.True(t, gotSpeed, "in-window speed value missing") + assert.True(t, gotLoc, "in-window location value missing") + }) + + t.Run("range exceeding the window is rejected, naming the window", func(t *testing.T) { + var resp any + err := c.Post(signalsQuery(base.Add(-96*time.Hour), base, + `speed(agg: MAX) currentLocationCoordinates(agg: LAST) { latitude }`), &resp) + require.Error(t, err) + assert.ErrorContains(t, err, "data window") + assert.ErrorContains(t, err, "currentLocationCoordinates") + }) + + t.Run("same wide range without the windowed signal succeeds", func(t *testing.T) { + var resp struct { + Signals []struct { + Timestamp string + Speed *float64 + } + } + err := c.Post(signalsQuery(base.Add(-96*time.Hour), base, `speed(agg: MAX)`), &resp) + require.NoError(t, err) + var maxSeen float64 + for _, row := range resp.Signals { + if row.Speed != nil && *row.Speed > maxSeen { + maxSeen = *row.Speed + } + } + assert.Equal(t, 70.0, maxSeen, "unconditional signal should see the full range") + }) +} + +func TestScopedWindowsGraphQL_Latest(t *testing.T) { + svc, subject, base, _, inTS := scopedFixture(t) + + const latestQuery = `query($subject: String!) { signalsLatest(subject: $subject) { + lastSeen + speed { timestamp value } + currentLocationCoordinates { timestamp value { latitude } } + } }` + + type latestResp struct { + SignalsLatest struct { + LastSeen *string + Speed *struct { + Timestamp string + Value float64 + } + CurrentLocationCoordinates *struct { + Timestamp string + Value struct{ Latitude float64 } + } + } + } + + t.Run("latest inside the window is served, lastSeen suppressed", func(t *testing.T) { + c := newScopedGraphQLClient(t, svc, scopedLocToken(subject, base.Add(-48*time.Hour), base.Add(time.Hour))) + var resp latestResp + require.NoError(t, c.Post(latestQuery, &resp, client.Var("subject", subject))) + require.NotNil(t, resp.SignalsLatest.Speed) + assert.Equal(t, 70.0, resp.SignalsLatest.Speed.Value) + require.NotNil(t, resp.SignalsLatest.CurrentLocationCoordinates, "in-window latest location should be visible") + assert.Equal(t, 41.0, resp.SignalsLatest.CurrentLocationCoordinates.Value.Latitude) + assert.Nil(t, resp.SignalsLatest.LastSeen, "lastSeen must be suppressed for scoped tokens") + }) + + t.Run("latest outside the window is withheld", func(t *testing.T) { + // Window ends before inTS: the latest location (inTS) is out of + // window and must not be shown; the unconditional speed still is. + c := newScopedGraphQLClient(t, svc, scopedLocToken(subject, base.Add(-48*time.Hour), inTS.Add(-time.Hour))) + var resp latestResp + require.NoError(t, c.Post(latestQuery, &resp, client.Var("subject", subject))) + require.NotNil(t, resp.SignalsLatest.Speed) + assert.Equal(t, 70.0, resp.SignalsLatest.Speed.Value) + assert.Nil(t, resp.SignalsLatest.CurrentLocationCoordinates, + "latest location recorded outside the window must be withheld") + }) +} + +func TestScopedWindowsGraphQL_ApproxLocationSeparatelyGated(t *testing.T) { + svc, subject, base, _, inTS := scopedFixture(t) + // Approximate location unconditional; raw location windowed to BEFORE the + // latest fix. The raw coordinates must be withheld while the approximate + // value — derived from the same row — is served. + tok := &tokenclaims.Token{CustomClaims: tokenclaims.CustomClaims{ + Asset: subject, + Permissions: []string{tokenclaims.PermissionGetApproximateLocation}, + ScopedPermissions: []tokenclaims.ScopedPermission{{ + Name: tokenclaims.PermissionGetLocationHistory, + Constraint: recordedAtWindow(base.Add(-48*time.Hour), inTS.Add(-time.Hour)), + }}, + }} + c := newScopedGraphQLClient(t, svc, tok) + + var resp struct { + SignalsLatest struct { + CurrentLocationCoordinates *struct { + Value struct{ Latitude float64 } + } + CurrentLocationApproximateCoordinates *struct { + Value struct{ Latitude float64 } + } + } + } + require.NoError(t, c.Post(`query($subject: String!) { signalsLatest(subject: $subject) { + currentLocationCoordinates { value { latitude } } + currentLocationApproximateCoordinates { value { latitude } } + } }`, &resp, client.Var("subject", subject))) + + assert.Nil(t, resp.SignalsLatest.CurrentLocationCoordinates, + "raw coordinates outside the location window must be withheld") + require.NotNil(t, resp.SignalsLatest.CurrentLocationApproximateCoordinates, + "approximate location under an unconditional grant must be served") + // H3-snapped, so not exactly 41.0 — just sanity-check the ballpark. + assert.InDelta(t, 41.0, resp.SignalsLatest.CurrentLocationApproximateCoordinates.Value.Latitude, 1.0) +} + +func TestScopedWindowsGraphQL_AllTimeSurfaces(t *testing.T) { + svc, subject, base, _, _ := scopedFixture(t) + c := newScopedGraphQLClient(t, svc, scopedLocToken(subject, base.Add(-48*time.Hour), base.Add(time.Hour))) + + t.Run("availableSignals excludes the scoped signal", func(t *testing.T) { + var resp struct{ AvailableSignals []string } + require.NoError(t, c.Post(`query($subject: String!) { availableSignals(subject: $subject) }`, + &resp, client.Var("subject", subject))) + assert.Contains(t, resp.AvailableSignals, "speed") + assert.NotContains(t, resp.AvailableSignals, "currentLocationCoordinates", + "scoped signals must not appear on all-time surfaces") + }) + + t.Run("dataSummary excludes the scoped signal and drops event summaries", func(t *testing.T) { + var resp struct { + DataSummary struct { + NumberOfSignals int + AvailableSignals []string + SignalDataSummary []struct { + Name string + NumberOfSignals int + } + EventDataSummary []struct{ Name string } + } + } + require.NoError(t, c.Post(`query($subject: String!) { dataSummary(subject: $subject) { + numberOfSignals availableSignals + signalDataSummary { name numberOfSignals } + eventDataSummary { name } + } }`, &resp, client.Var("subject", subject))) + require.Len(t, resp.DataSummary.SignalDataSummary, 1) + assert.Equal(t, "speed", resp.DataSummary.SignalDataSummary[0].Name) + assert.Equal(t, 2, resp.DataSummary.NumberOfSignals, "count must be recomputed over surviving signals") + assert.NotContains(t, resp.DataSummary.AvailableSignals, "currentLocationCoordinates") + assert.Empty(t, resp.DataSummary.EventDataSummary, + "event summaries must be dropped when the history permissions are not both unconditional") + }) + + t.Run("snapshot serves in-window scoped values but suppresses lastSeen", func(t *testing.T) { + var resp struct { + SignalsSnapshot struct { + LastSeen *string + Signals []struct{ Name string } + } + } + require.NoError(t, c.Post(`query($subject: String!) { signalsSnapshot(subject: $subject) { + lastSeen signals { name } + } }`, &resp, client.Var("subject", subject))) + names := make(map[string]bool) + for _, s := range resp.SignalsSnapshot.Signals { + names[s.Name] = true + } + assert.True(t, names["speed"]) + assert.True(t, names["currentLocationCoordinates"], + "snapshot is a point query: in-window scoped values are served") + assert.Nil(t, resp.SignalsSnapshot.LastSeen) + }) + + t.Run("events reject ranges outside the held window", func(t *testing.T) { + eventsQuery := func(from, to time.Time) string { + return fmt.Sprintf(`query { events(subject:%q, from:%q, to:%q) { timestamp name } }`, + subject, from.Format(time.RFC3339), to.Format(time.RFC3339)) + } + var resp any + err := c.Post(eventsQuery(base.Add(-96*time.Hour), base), &resp) + require.Error(t, err) + assert.ErrorContains(t, err, "data window") + + require.NoError(t, c.Post(eventsQuery(base.Add(-40*time.Hour), base.Add(-12*time.Hour)), &resp), + "in-window events range must be accepted") + }) +} + +func TestScopedWindowsGraphQL_UnheldSignalIsAFieldError(t *testing.T) { + svc, subject, base, _, _ := scopedFixture(t) + // The token holds ONLY the scoped location permission — no non-location + // history at all. Requesting speed over any range must fail as a FIELD + // possession error from the directive, not a whole-query window rejection: + // the range check only speaks for permissions the token actually holds. + tok := &tokenclaims.Token{CustomClaims: tokenclaims.CustomClaims{ + Asset: subject, + ScopedPermissions: []tokenclaims.ScopedPermission{{ + Name: tokenclaims.PermissionGetLocationHistory, + Constraint: recordedAtWindow(base.Add(-48*time.Hour), base.Add(time.Hour)), + }}, + }} + c := newScopedGraphQLClient(t, svc, tok) + + var resp any + err := c.Post(fmt.Sprintf(`query { signals(subject:%q, interval:"1h", from:%q, to:%q) { timestamp speed(agg: MAX) } }`, + subject, base.Add(-96*time.Hour).Format(time.RFC3339), base.Format(time.RFC3339)), &resp) + require.Error(t, err) + assert.ErrorContains(t, err, "missing required privilege") + assert.NotContains(t, err.Error(), "data window", + "an unheld permission is a possession problem, not a window problem") +}