Skip to content
Merged
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
164 changes: 120 additions & 44 deletions pkg/tenancy/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"github.com/uug-ai/database/pkg/database"
"github.com/uug-ai/models/pkg/models"
"github.com/uug-ai/models/pkg/properties"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
Expand Down Expand Up @@ -81,6 +82,20 @@ type Ownership struct {
ProjectId primitive.ObjectID
}

// DeviceLookup is trusted server-side context for selecting one persisted
// device. DeviceId is authoritative when present. Otherwise a project scope
// requires its parent organisation, and organisation scope retains the legacy
// owner fallback for unstamped devices. A key alone is never sufficient.
type DeviceLookup struct {
DeviceId primitive.ObjectID
DeviceKey string
OrganisationId primitive.ObjectID
ProjectId primitive.ObjectID
LegacyOwnerId primitive.ObjectID
}

var ErrDeviceScopeRequired = errors.New("trusted device scope is required")

// DeviceResolver resolves device ownership against one database.
type DeviceResolver struct {
db *database.Database
Expand All @@ -93,13 +108,8 @@ func NewDeviceResolver(db *database.Database, collections Collections) *DeviceRe
return &DeviceResolver{db: db, collections: collections.withDefaults()}
}

// persistedOrganisationOwnership reads only the canonical owner. Unlike the
// resource collections, the organisation document never had a snake_case owner
// field: models.Organisation has always declared bson:"ownerId", and it is the
// only spelling the ownerId_1 index covers.
type persistedOrganisationOwnership struct {
Id primitive.ObjectID `bson:"_id"`
OwnerId primitive.ObjectID `bson:"ownerId"`
Id primitive.ObjectID `bson:"_id"`
}

type persistedUserOwnership struct {
Expand All @@ -122,6 +132,9 @@ func DeviceProjection() *database.Projection {
}

// LoadDevice reads the ownership fields of exactly one source device by its key.
//
// Deprecated: key-only lookup cannot distinguish the same non-secret key in
// different projects. New ingress paths must use LoadScopedDevice.
func (r *DeviceResolver) LoadDevice(ctx context.Context, deviceKey string) (models.Device, error) {
if r == nil || r.db == nil || r.db.Client == nil {
return models.Device{}, fmt.Errorf("source device %q lookup requires a database", deviceKey)
Expand Down Expand Up @@ -155,6 +168,104 @@ func (r *DeviceResolver) LoadDevice(ctx context.Context, deviceKey string) (mode
return device, nil
}

// LoadScopedDevice selects exactly one device within trusted server-side
// identity. It permits the same non-secret device key in other projects while
// still rejecting duplicates inside the selected scope.
func (r *DeviceResolver) LoadScopedDevice(ctx context.Context, lookup DeviceLookup) (models.Device, error) {
if r == nil || r.db == nil || r.db.Client == nil {
return models.Device{}, fmt.Errorf("source device %q lookup requires a database", lookup.DeviceKey)
}
if lookup.DeviceKey == "" {
return models.Device{}, fmt.Errorf("%w: device key is empty", ErrDeviceScopeRequired)
}

filter, err := scopedDeviceFilter(lookup)
if err != nil {
return models.Device{}, err
}

databaseCtx, cancel := context.WithTimeout(ctx, r.db.Client.GetTimeout())
defer cancel()

var devices []models.Device
err = r.db.Client.Find(
databaseCtx,
r.collections.Database,
r.collections.Devices,
filter,
DeviceProjection(),
options.Find().SetLimit(2),
).All(&devices)
if err != nil {
return models.Device{}, fmt.Errorf("find scoped source device %q: %w", lookup.DeviceKey, err)
}
if len(devices) == 0 {
return models.Device{}, fmt.Errorf("find scoped source device %q: %w", lookup.DeviceKey, mongo.ErrNoDocuments)
}
if len(devices) > 1 {
return models.Device{}, fmt.Errorf("source device key %q resolves to multiple persisted devices inside its trusted scope", lookup.DeviceKey)
}
if devices[0].Id.IsZero() {
return models.Device{}, fmt.Errorf("source device %q has no persisted identity", lookup.DeviceKey)
}
return devices[0], nil
}

func scopedDeviceFilter(lookup DeviceLookup) (bson.M, error) {
if !lookup.DeviceId.IsZero() {
return bson.M{
properties.DeviceId: lookup.DeviceId,
properties.DeviceKey: lookup.DeviceKey,
}, nil
}

legacyOwnerId := lookup.LegacyOwnerId
if legacyOwnerId.IsZero() {
legacyOwnerId = lookup.OrganisationId
}

if !lookup.ProjectId.IsZero() {
if lookup.OrganisationId.IsZero() {
return nil, fmt.Errorf("%w: project scope has no parent organisation", ErrDeviceScopeRequired)
}
filter := database.ScopeWithProject(
lookup.OrganisationId.Hex(),
properties.DeviceUserId,
legacyOwnerId.Hex(),
lookup.ProjectId,
)
for _, arm := range filter["$or"].([]bson.M) {
arm[properties.DeviceKey] = lookup.DeviceKey
}
return filter, nil
}

if !lookup.OrganisationId.IsZero() {
filter := database.CanonicalFirstOwnership(
lookup.OrganisationId.Hex(),
properties.DeviceUserId,
legacyOwnerId.Hex(),
)
for _, arm := range filter["$or"].([]bson.M) {
arm[properties.DeviceKey] = lookup.DeviceKey
}
return filter, nil
}

if !lookup.LegacyOwnerId.IsZero() {
filter := bson.M{
properties.DeviceKey: lookup.DeviceKey,
properties.DeviceUserId: lookup.LegacyOwnerId.Hex(),
}
for field, test := range database.MissingCanonicalOrganisation() {
filter[field] = test
}
return filter, nil
}

return nil, fmt.Errorf("%w for key %q", ErrDeviceScopeRequired, lookup.DeviceKey)
}

// ResolveDeviceByKey loads a device and resolves its ownership in one step.
func (r *DeviceResolver) ResolveDeviceByKey(ctx context.Context, deviceKey string) (models.Device, Ownership, error) {
device, err := r.LoadDevice(ctx, deviceKey)
Expand Down Expand Up @@ -227,22 +338,9 @@ func (r *DeviceResolver) resolveOrganisation(ctx context.Context, device models.
}
}

organisations, err := r.findOrganisationsByOwner(ctx, ownerId)
if err != nil {
return primitive.NilObjectID, err
}
if len(organisations) == 1 {
return organisations[0].Id, nil
}
if len(organisations) > 1 {
return primitive.NilObjectID, fmt.Errorf("source device %q legacy owner resolves to %d organisations", device.Key, len(organisations))
}

// No organisation record exists at all, so organisations-bootstrap has not
// run on this instance. A primary organisation deterministically reuses the
// master user's id, so deriving it here produces exactly the value the
// migration will later persist. Failing instead would drop every message on
// an un-migrated deployment.
// A legacy owner id deterministically denotes that owner's default
// organisation; it never selects one of the owner's secondary organisations.
// This produces exactly the identity organisations-bootstrap materialises.
//
// The derivation is only meaningful if the owner has a user record, because
// bootstrap mints one organisation per master user. An owner with no user
Expand Down Expand Up @@ -300,25 +398,3 @@ func (r *DeviceResolver) findUserOwnership(ctx context.Context, id primitive.Obj
}
return user, true, nil
}

func (r *DeviceResolver) findOrganisationsByOwner(ctx context.Context, ownerId primitive.ObjectID) ([]persistedOrganisationOwnership, error) {
databaseCtx, cancel := context.WithTimeout(ctx, r.db.Client.GetTimeout())
defer cancel()

// A plain equality rather than an ownership $or: organisations only ever
// stored the canonical owner, so a second arm would add no reachable
// document while costing the ownerId_1 index. An $or is only index-served
// when every arm is index-bounded, and no owner_id index exists to bound
// one — the whole lookup would degrade to a collection scan.
filter := map[string]any{properties.OrganisationOwnerId: ownerId}
var organisations []persistedOrganisationOwnership
if err := r.db.Client.Find(databaseCtx, r.collections.Database, r.collections.Organisations, filter, options.Find().SetLimit(2)).All(&organisations); err != nil {
return nil, fmt.Errorf("find organisations owned by %s: %w", ownerId.Hex(), err)
}
for _, organisation := range organisations {
if organisation.Id.IsZero() {
return nil, fmt.Errorf("organisation owned by %s has no persisted identity", ownerId.Hex())
}
}
return organisations, nil
}
Loading
Loading