diff --git a/pkg/scd/models/models.go b/pkg/scd/models/models.go index e9d188961..25475d6e9 100644 --- a/pkg/scd/models/models.go +++ b/pkg/scd/models/models.go @@ -3,11 +3,14 @@ package models import ( "crypto/sha256" "encoding/base64" + "fmt" "net/url" "strings" "time" + "github.com/google/uuid" restapi "github.com/interuss/dss/pkg/api/scdv1" + dssmodels "github.com/interuss/dss/pkg/models" "github.com/interuss/stacktrace" ) @@ -20,6 +23,10 @@ const ( // Note that this UUID is not meant to be persisted to the database: it should only be used // to populate required API fields for which a proper value does not exist. NullV4UUID = restapi.SubscriptionID("00000000-0000-4000-8000-000000000000") + + // maxClockSkew is the largest allowed interval between a client-provided + // time and the server's idea of the current time. + maxClockSkew = time.Minute * 5 ) type ( @@ -43,6 +50,28 @@ func NewOVNFromTime(t time.Time, salt string) OVN { return OVN(ovn) } +// NewOVNFromUUIDv7Suffix returns an OVN based on an UUIDv7 suffix: `{op_intent_id}_{uuidv7_suffix}`. +// It validates that the suffix is indeed a UUIDv7 and that its timestamp is not too far from now. +func NewOVNFromUUIDv7Suffix(now time.Time, oiID dssmodels.ID, suffix string) (OVN, error) { + uuidV7, err := uuid.Parse(suffix) + if err != nil { + return "", stacktrace.Propagate(err, "Suffix `%s` is not a valid UUID", suffix) + } + if uuidV7.Version() != 7 { + return "", stacktrace.NewError("Suffix `%s` is not version 7 but version %d", suffix, uuidV7.Version()) + } + + var ( + ovnTime = time.Unix(uuidV7.Time().UnixTime()) + skew = now.Sub(ovnTime).Abs() + ) + if skew > maxClockSkew { + return "", stacktrace.NewError("Suffix `%s` is too far away from now (got %s, max is %s)", suffix, skew.String(), maxClockSkew.String()) + } + + return OVN(fmt.Sprintf("%s_%s", oiID.String(), suffix)), nil +} + // Empty returns true if ovn indicates an empty opaque version number. func (ovn OVN) Empty() bool { return len(ovn) == 0 diff --git a/pkg/scd/models/models_test.go b/pkg/scd/models/models_test.go index 4877a08aa..ee9d408e9 100644 --- a/pkg/scd/models/models_test.go +++ b/pkg/scd/models/models_test.go @@ -5,9 +5,105 @@ import ( "time" "github.com/google/uuid" + dssmodels "github.com/interuss/dss/pkg/models" "github.com/stretchr/testify/require" ) func TestOVNFromTimeIsValid(t *testing.T) { require.True(t, NewOVNFromTime(time.Now(), uuid.New().String()).Valid()) } + +func TestNewOVNFromUUIDv7Suffix(t *testing.T) { + type cases []struct { + name string + + now time.Time + oiID dssmodels.ID + suffix string + + ovn string + } + + t.Run("valid", func(t *testing.T) { + testCases := cases{{ + name: "exact", + now: time.Date(2024, time.September, 10, 13, 02, 42, int(408*time.Millisecond), time.UTC), + oiID: "bd65d3de-f52e-419d-acfb-ad85d557de99", + suffix: "0191dc07-76e8-7546-84f2-e739e9f44d77", // 2024-09-10T13:02:42.408Z + ovn: "bd65d3de-f52e-419d-acfb-ad85d557de99_0191dc07-76e8-7546-84f2-e739e9f44d77", + }, { + name: "before", + now: time.Date(2024, time.September, 10, 13, 02, 24, 0, time.UTC), + oiID: "e72589d4-8c14-4d6f-bd9c-1bfb8704e332", + suffix: "0191dc07-2f57-79fd-b021-80456ceb627f", // 2024-09-10T13:02:24.087Z + ovn: "e72589d4-8c14-4d6f-bd9c-1bfb8704e332_0191dc07-2f57-79fd-b021-80456ceb627f", + }, { + name: "after", + now: time.Date(2024, time.September, 10, 13, 02, 48, 0, time.UTC), + oiID: "f577437f-bc6b-4826-9c6b-7831b78eabcc", + suffix: "0191dc07-8a71-7a12-87ed-9baa6e889874", // 2024-09-10T13:02:47.409Z + ovn: "f577437f-bc6b-4826-9c6b-7831b78eabcc_0191dc07-8a71-7a12-87ed-9baa6e889874", + }, { + name: "before - max skew", + now: time.Date(2024, time.September, 10, 12, 57, 25, 0, time.UTC), + oiID: "e72589d4-8c14-4d6f-bd9c-1bfb8704e332", + suffix: "0191dc07-2f57-79fd-b021-80456ceb627f", // 2024-09-10T13:02:24.087Z + ovn: "e72589d4-8c14-4d6f-bd9c-1bfb8704e332_0191dc07-2f57-79fd-b021-80456ceb627f", + }, { + name: "after - max skew", + now: time.Date(2024, time.September, 10, 13, 07, 47, 0, time.UTC), + oiID: "f577437f-bc6b-4826-9c6b-7831b78eabcc", + suffix: "0191dc07-8a71-7a12-87ed-9baa6e889874", // 2024-09-10T13:02:47.409Z + ovn: "f577437f-bc6b-4826-9c6b-7831b78eabcc_0191dc07-8a71-7a12-87ed-9baa6e889874", + }} + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + ovn, err := NewOVNFromUUIDv7Suffix(testCase.now, testCase.oiID, testCase.suffix) + require.NoError(t, err) + require.EqualValues(t, testCase.ovn, ovn) + }) + } + }) + + t.Run("invalid", func(t *testing.T) { + testCases := cases{{ + name: "before - past skew", + now: time.Date(2024, time.September, 10, 12, 57, 24, 0, time.UTC), + oiID: "e72589d4-8c14-4d6f-bd9c-1bfb8704e332", + suffix: "0191dc07-2f57-79fd-b021-80456ceb627f", // 2024-09-10T13:02:24.087Z + }, { + name: "after - past skew", + now: time.Date(2024, time.September, 10, 13, 07, 48, 0, time.UTC), + oiID: "f577437f-bc6b-4826-9c6b-7831b78eabcc", + suffix: "0191dc07-8a71-7a12-87ed-9baa6e889874", // 2024-09-10T13:02:47.409Z + }, { + name: "before - long past skew", + now: time.Date(2024, time.September, 10, 11, 57, 24, 0, time.UTC), + oiID: "e72589d4-8c14-4d6f-bd9c-1bfb8704e332", + suffix: "0191dc07-2f57-79fd-b021-80456ceb627f", // 2024-09-10T13:02:24.087Z + }, { + name: "after - long past skew", + now: time.Date(2024, time.September, 10, 14, 07, 48, 0, time.UTC), + oiID: "f577437f-bc6b-4826-9c6b-7831b78eabcc", + suffix: "0191dc07-8a71-7a12-87ed-9baa6e889874", // 2024-09-10T13:02:47.409Z + }, { + name: "uuidv4", + now: time.Date(2024, time.September, 10, 13, 02, 24, 0, time.UTC), + oiID: "e72589d4-8c14-4d6f-bd9c-1bfb8704e332", + suffix: "44299cb9-a722-4d9c-87bc-537a5aeb2b73", + }, { + name: "not uuid", + now: time.Date(2024, time.September, 10, 13, 02, 24, 0, time.UTC), + oiID: "not_a_uuid", + suffix: "44299cb9-a722-4d9c-87bc-537a5aeb2b73", + }} + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + _, err := NewOVNFromUUIDv7Suffix(testCase.now, testCase.oiID, testCase.suffix) + require.Error(t, err) + }) + } + }) +} diff --git a/pkg/scd/models/subscriptions.go b/pkg/scd/models/subscriptions.go index 8e5ffcd59..aa66ecb5f 100644 --- a/pkg/scd/models/subscriptions.go +++ b/pkg/scd/models/subscriptions.go @@ -15,10 +15,6 @@ const ( // maxSubscriptionDuration is the largest allowed interval between StartTime // and EndTime. maxSubscriptionDuration = time.Hour * 24 - - // maxClockSkew is the largest allowed interval between the StartTime of a new - // subscription and the server's idea of the current time. - maxClockSkew = time.Minute * 5 ) // Subscription represents an SCD subscription diff --git a/pkg/scd/operational_intents_handler.go b/pkg/scd/operational_intents_handler.go index 86b8732e5..0c17e082f 100644 --- a/pkg/scd/operational_intents_handler.go +++ b/pkg/scd/operational_intents_handler.go @@ -315,7 +315,7 @@ func (a *Server) CreateOperationalIntentReference(ctx context.Context, req *rest Message: dsserr.Handle(ctx, stacktrace.PropagateWithCode(req.BodyParseError, dsserr.BadRequest, "Malformed params"))}} } - respOK, respConflict, err := a.upsertOperationalIntentReference(ctx, &req.Auth, req.Entityid, "", req.Body) + respOK, respConflict, err := a.upsertOperationalIntentReference(ctx, time.Now(), &req.Auth, req.Entityid, "", req.Body) if err != nil { err = stacktrace.Propagate(err, "Could not put Operational Intent Reference") errResp := &restapi.ErrorResponse{Message: dsserr.Handle(ctx, err)} @@ -351,7 +351,7 @@ func (a *Server) UpdateOperationalIntentReference(ctx context.Context, req *rest Message: dsserr.Handle(ctx, stacktrace.PropagateWithCode(req.BodyParseError, dsserr.BadRequest, "Malformed params"))}} } - respOK, respConflict, err := a.upsertOperationalIntentReference(ctx, &req.Auth, req.Entityid, req.Ovn, req.Body) + respOK, respConflict, err := a.upsertOperationalIntentReference(ctx, time.Now(), &req.Auth, req.Entityid, req.Ovn, req.Body) if err != nil { err = stacktrace.Propagate(err, "Could not put subscription") errResp := &restapi.ErrorResponse{Message: dsserr.Handle(ctx, err)} @@ -377,6 +377,7 @@ func (a *Server) UpdateOperationalIntentReference(ctx context.Context, req *rest type validOIRParams struct { id dssmodels.ID ovn scdmodels.OVN + newOVN scdmodels.OVN state scdmodels.OperationalIntentState extents []*dssmodels.Volume4D uExtent *dssmodels.Volume4D @@ -404,7 +405,7 @@ func (vp *validOIRParams) toOIR(manager dssmodels.Manager, attachedSub *scdmodel ID: vp.id, Manager: manager, Version: version, - OVN: "", // TODO dss#1078: this field must be populated to support USSs setting OVNs in advance + OVN: vp.newOVN, // non-empty only if the USS has requested an OVN PastOVNs: pastOVNs, StartTime: vp.uExtent.StartTime, @@ -423,6 +424,7 @@ func (vp *validOIRParams) toOIR(manager dssmodels.Manager, attachedSub *scdmodel // Note that this does NOT check for anything related to access controls: any error returned should be labeled // as a dsserr.BadRequest. func validateAndReturnUpsertParams( + now time.Time, entityid restapi.EntityID, ovn restapi.EntityOVN, params *restapi.PutOperationalIntentReferenceParameters, @@ -509,7 +511,7 @@ func validateAndReturnUpsertParams( return nil, stacktrace.NewError("Missing time_end from extents") } - if time.Now().After(*valid.uExtent.EndTime) { + if now.After(*valid.uExtent.EndTime) { return nil, stacktrace.NewError("OperationalIntents may not end in the past") } @@ -531,6 +533,13 @@ func validateAndReturnUpsertParams( } valid.ovn = scdmodels.OVN(ovn) + if params.RequestedOvnSuffix != nil { + valid.newOVN, err = scdmodels.NewOVNFromUUIDv7Suffix(now, valid.id, string(*params.RequestedOvnSuffix)) + if err != nil { + return nil, stacktrace.Propagate(err, "Invalid requested OVN suffix") + } + } + // Check if a subscription is required for this request: // OIRs in an accepted state do not need a subscription. if valid.state.RequiresSubscription() && @@ -798,11 +807,11 @@ func ensureSubscriptionCoversOIR(ctx context.Context, r repos.Repository, sub *s // upsertOperationalIntentReference inserts or updates an Operational Intent. // If the ovn argument is empty (""), it will attempt to create a new Operational Intent. -func (a *Server) upsertOperationalIntentReference(ctx context.Context, authorizedManager *api.AuthorizationResult, entityid restapi.EntityID, ovn restapi.EntityOVN, params *restapi.PutOperationalIntentReferenceParameters, +func (a *Server) upsertOperationalIntentReference(ctx context.Context, now time.Time, authorizedManager *api.AuthorizationResult, entityid restapi.EntityID, ovn restapi.EntityOVN, params *restapi.PutOperationalIntentReferenceParameters, ) (*restapi.ChangeOperationalIntentReferenceResponse, *restapi.AirspaceConflictResponse, error) { // Note: validateAndReturnUpsertParams and checkUpsertPermissionsAndReturnManager could be moved out of this method and only the valid params passed, // but this requires some changes in the caller that go beyond the immediate scope of #1088 and can be done later. - validParams, err := validateAndReturnUpsertParams(entityid, ovn, params, a.AllowHTTPBaseUrls) + validParams, err := validateAndReturnUpsertParams(now, entityid, ovn, params, a.AllowHTTPBaseUrls) if err != nil { return nil, nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Failed to validate Operational Intent Reference upsert parameters") }