Split employee devices from DeviceConnection

viewer.enrolledDevices shared DeviceConnection with the admin
org list, so totalCount had to authorize with both
employee-device:list and device:list. Mirror
EmployeeDocumentConnection: a dedicated EmployeeDeviceConnection
without totalCount, and keep DeviceConnection.totalCount for
the org fleet only. Cover assumed-session device:get IDOR in e2e.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-07-21 15:06:12 +02:00
parent 85864a580c
commit 6cac6a8775
7 changed files with 67 additions and 27 deletions

View File

@@ -66,21 +66,6 @@ func (r *deviceResolver) LatestPostures(ctx context.Context, obj *types.Device)
// TotalCount is the resolver for the DeviceConnection.totalCount field.
func (r *deviceConnectionResolver) TotalCount(ctx context.Context, obj *types.DeviceConnection) (int, error) {
if obj.OwnerID != nil {
scope, err := r.authorize(ctx, obj.ParentID, itam.ActionEmployeeDeviceList)
if err != nil {
return 0, err
}
count, err := r.itam.CountForOrganizationIDAndOwnerID(ctx, scope, obj.ParentID, *obj.OwnerID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count devices by owner", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
scope, err := r.authorize(ctx, obj.ParentID, itam.ActionDeviceList)
if err != nil {
return 0, err

View File

@@ -119,6 +119,22 @@ type DeviceEdge {
node: Device!
}
type EmployeeDeviceConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDeviceConnection"
) {
edges: [EmployeeDeviceEdge!]!
pageInfo: PageInfo!
}
type EmployeeDeviceEdge
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDeviceEdge"
) {
cursor: CursorKey!
node: Device!
}
type CreateDevicePayload {
device: Device!
# enrollmentToken is shown ONCE; exchange via agent REST /enroll.

View File

@@ -30,7 +30,7 @@ type Viewer {
last: Int
before: CursorKey
orderBy: DeviceOrder
): DeviceConnection! @goField(forceResolver: true)
): EmployeeDeviceConnection! @goField(forceResolver: true)
# Own-device read for self-enrollment status polling before org assumption.
enrolledDevice(id: ID!): Device @goField(forceResolver: true)

View File

@@ -36,7 +36,16 @@ type (
Resolver any
ParentID gid.GID
OwnerID *gid.GID
}
EmployeeDeviceConnection struct {
Edges []*EmployeeDeviceEdge
PageInfo *PageInfo
}
EmployeeDeviceEdge struct {
Cursor page.CursorKey
Node *Device
}
)
@@ -58,16 +67,18 @@ func NewDeviceConnection(
}
}
func NewOwnedDeviceConnection(
func NewEmployeeDeviceConnection(
p *page.Page[*coredata.Device, coredata.DeviceOrderField],
parentType any,
parentID gid.GID,
ownerID gid.GID,
) *DeviceConnection {
conn := NewDeviceConnection(p, parentType, parentID)
conn.OwnerID = &ownerID
) *EmployeeDeviceConnection {
edges := make([]*EmployeeDeviceEdge, len(p.Data))
for i := range edges {
edges[i] = NewEmployeeDeviceEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return conn
return &EmployeeDeviceConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
}
}
func NewDeviceEdge(d *coredata.Device, orderBy coredata.DeviceOrderField) *DeviceEdge {
@@ -77,6 +88,16 @@ func NewDeviceEdge(d *coredata.Device, orderBy coredata.DeviceOrderField) *Devic
}
}
func NewEmployeeDeviceEdge(
d *coredata.Device,
orderBy coredata.DeviceOrderField,
) *EmployeeDeviceEdge {
return &EmployeeDeviceEdge{
Cursor: d.CursorKey(orderBy),
Node: NewDevice(d),
}
}
func NewDevice(d *coredata.Device) *Device {
device := &Device{
ID: d.ID,

View File

@@ -184,7 +184,7 @@ func (r *viewerResolver) ApprovableDocument(ctx context.Context, obj *types.View
}
// EnrolledDevices is the resolver for the enrolledDevices field.
func (r *viewerResolver) EnrolledDevices(ctx context.Context, obj *types.Viewer, organizationID gid.GID, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DeviceOrderBy) (*types.DeviceConnection, error) {
func (r *viewerResolver) EnrolledDevices(ctx context.Context, obj *types.Viewer, organizationID gid.GID, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DeviceOrderBy) (*types.EmployeeDeviceConnection, error) {
scope, err := r.authorize(ctx, organizationID, itam.ActionEmployeeDeviceList)
if err != nil {
return nil, err
@@ -228,7 +228,7 @@ func (r *viewerResolver) EnrolledDevices(ctx context.Context, obj *types.Viewer,
return nil, gqlutils.Internal(ctx)
}
return types.NewOwnedDeviceConnection(devicesPage, r, organizationID, profile.ID), nil
return types.NewEmployeeDeviceConnection(devicesPage), nil
}
// EnrolledDevice is the resolver for the enrolledDevice field.