diff --git a/e2e/console/device_enrollment_test.go b/e2e/console/device_enrollment_test.go index 513dedf47..735442937 100644 --- a/e2e/console/device_enrollment_test.go +++ b/e2e/console/device_enrollment_test.go @@ -528,6 +528,22 @@ func TestDeviceEnrollment(t *testing.T) { require.Equal(t, "PENDING", result.Node.State) }) + t.Run("employee cannot read another users device via node", func(t *testing.T) { + t.Parallel() + + owner, _, _, _, orgID, _ := setupDeviceEnrollmentClients(t) + + employeeA := testutil.NewClientInOrg(t, testutil.RoleEmployee, owner) + employeeB := testutil.NewClientInOrg(t, testutil.RoleEmployee, owner) + + enrolledB := enrollDevice(t, employeeB, orgID) + + _, err := employeeA.Do(getDeviceQuery, map[string]any{ + "id": enrolledB.EnrollDevice.Device.ID, + }) + testutil.RequireForbiddenError(t, err, "employee cannot read another users device via node") + }) + t.Run("employee cannot list org devices", func(t *testing.T) { t.Parallel() diff --git a/pkg/itam/policies.go b/pkg/itam/policies.go index f9202d5d1..fdcc26e7a 100644 --- a/pkg/itam/policies.go +++ b/pkg/itam/policies.go @@ -57,6 +57,8 @@ var ViewerPolicy = policy.NewPolicy( ).WithDescription("Read-only ITAM access for organization viewers") // EmployeePolicy grants self-enrollment access to organization employees. +// Employee-device list/get mirror core:employee-document:*: a dedicated +// surface so employees can read their own devices without itam:device:list. var EmployeePolicy = policy.NewPolicy( "itam:employee", "ITAM Employee", diff --git a/pkg/server/api/console/v1/device_resolvers.go b/pkg/server/api/console/v1/device_resolvers.go index 2ce20b75a..8fad3eccc 100644 --- a/pkg/server/api/console/v1/device_resolvers.go +++ b/pkg/server/api/console/v1/device_resolvers.go @@ -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 diff --git a/pkg/server/api/console/v1/graphql/device.graphql b/pkg/server/api/console/v1/graphql/device.graphql index 5f72b470d..61deb3d67 100644 --- a/pkg/server/api/console/v1/graphql/device.graphql +++ b/pkg/server/api/console/v1/graphql/device.graphql @@ -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. diff --git a/pkg/server/api/console/v1/graphql/viewer.graphql b/pkg/server/api/console/v1/graphql/viewer.graphql index 6f34ddfd7..e7cabeb41 100644 --- a/pkg/server/api/console/v1/graphql/viewer.graphql +++ b/pkg/server/api/console/v1/graphql/viewer.graphql @@ -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) diff --git a/pkg/server/api/console/v1/types/device.go b/pkg/server/api/console/v1/types/device.go index 3de4381d9..514353db5 100644 --- a/pkg/server/api/console/v1/types/device.go +++ b/pkg/server/api/console/v1/types/device.go @@ -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, diff --git a/pkg/server/api/console/v1/viewer_resolvers.go b/pkg/server/api/console/v1/viewer_resolvers.go index 188465bce..48d2dc992 100644 --- a/pkg/server/api/console/v1/viewer_resolvers.go +++ b/pkg/server/api/console/v1/viewer_resolvers.go @@ -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.