Add device enrollment API and agent protocol

Expose ITAM REST endpoints for agents, console GraphQL for device
management, and wire probod bootstrap with enrollment e2e coverage.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-07-14 20:39:45 +02:00
parent 1f79453386
commit e767dd8377
30 changed files with 2129 additions and 6 deletions

View File

@@ -0,0 +1,272 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
// Package agent_v1 exposes the REST surface that the probo-agent binary
// uses to heartbeat and push device posture results.
//
// All endpoints speak JSON; agents should not need a GraphQL client.
package agent_v1
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/bearertoken"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/itam"
"go.probo.inc/probo/pkg/server/api/agent/v1/types"
"go.probo.inc/probo/pkg/server/jsonx"
)
type Handler struct {
logger *log.Logger
itamSvc *itam.Service
}
func NewMux(logger *log.Logger, itamSvc *itam.Service) *chi.Mux {
h := &Handler{
logger: logger,
itamSvc: itamSvc,
}
r := chi.NewRouter()
r.Post("/enroll", h.handleEnroll)
r.Group(func(r chi.Router) {
r.Use(h.deviceAuthMiddleware)
r.Post("/heartbeat", h.handleHeartbeat)
r.Post("/postures", h.handlePostures)
r.Post("/unenroll", h.handleUnenroll)
})
return r
}
func (h *Handler) handleEnroll(w http.ResponseWriter, r *http.Request) {
defer func() { _ = r.Body.Close() }()
var req types.EnrollRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<14)).Decode(&req); err != nil {
jsonx.RenderBadRequest(w, fmt.Errorf("cannot decode request body: %w", err))
return
}
if err := req.Validate(); err != nil {
jsonx.RenderBadRequest(w, fmt.Errorf("invalid request body: %w", err))
return
}
apiKey, err := h.itamSvc.ExchangeEnrollmentToken(r.Context(), req.Token)
if err != nil {
switch {
case errors.Is(err, itam.ErrEnrollmentTokenExpired),
errors.Is(err, itam.ErrEnrollmentTokenAlreadyUsed),
errors.Is(err, itam.ErrEnrollmentTokenInvalid):
jsonx.RenderUnauthorized(w, errors.New("unauthorized"))
default:
h.logger.ErrorCtx(r.Context(), "cannot exchange enrollment token", log.Error(err))
jsonx.RenderInternalServerError(w)
}
return
}
httpserver.RenderJSON(w, http.StatusOK, types.EnrollResponse{APIKey: apiKey})
}
func (h *Handler) handleHeartbeat(w http.ResponseWriter, r *http.Request) {
defer func() { _ = r.Body.Close() }()
dev := deviceFromContext(r.Context())
if dev == nil {
jsonx.RenderUnauthorized(w, errors.New("unauthorized"))
return
}
var req types.HeartbeatRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<14)).Decode(&req); err != nil {
jsonx.RenderBadRequest(w, fmt.Errorf("cannot decode request body: %w", err))
return
}
if err := req.Validate(); err != nil {
jsonx.RenderBadRequest(w, fmt.Errorf("invalid request body: %w", err))
return
}
scope := coredata.NewScopeFromObjectID(dev.ID)
device, err := h.itamSvc.RecordHeartbeat(
r.Context(),
scope,
dev.ID,
itam.RecordHeartbeatRequest{
HardwareUUID: req.HardwareUUID,
SerialNumber: req.SerialNumber,
Hostname: req.Hostname,
Platform: req.Platform,
OSVersion: req.OSVersion,
AgentVersion: req.AgentVersion,
},
)
if err != nil {
if errors.Is(err, itam.ErrDeviceRevoked) {
jsonx.RenderUnauthorized(w, errors.New("device revoked"))
return
}
if errors.Is(err, itam.ErrDeviceHardwareConflict) {
jsonx.RenderBadRequest(w, errors.New("device hardware uuid already enrolled"))
return
}
h.logger.ErrorCtx(r.Context(), "cannot record heartbeat", log.Error(err))
jsonx.RenderInternalServerError(w)
return
}
httpserver.RenderJSON(w, http.StatusOK, types.NewHeartbeatResponse(device))
}
func (h *Handler) handlePostures(w http.ResponseWriter, r *http.Request) {
defer func() { _ = r.Body.Close() }()
dev := deviceFromContext(r.Context())
if dev == nil {
jsonx.RenderUnauthorized(w, errors.New("unauthorized"))
return
}
var req types.PostureRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
jsonx.RenderBadRequest(w, fmt.Errorf("cannot decode request body: %w", err))
return
}
if err := req.Validate(); err != nil {
jsonx.RenderBadRequest(w, fmt.Errorf("invalid request body: %w", err))
return
}
if len(req.Results) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
results := make([]itam.RecordPostureResult, 0, len(req.Results))
for _, pr := range req.Results {
results = append(
results,
itam.RecordPostureResult{
CheckKey: pr.CheckKey,
Status: pr.Status,
Evidence: pr.Evidence,
ObservedAt: pr.ObservedAt,
},
)
}
scope := coredata.NewScopeFromObjectID(dev.ID)
if err := h.itamSvc.RecordPostures(r.Context(), scope, dev.ID, results); err != nil {
if errors.Is(err, itam.ErrDeviceRevoked) {
jsonx.RenderUnauthorized(w, errors.New("device revoked"))
return
}
h.logger.ErrorCtx(r.Context(), "cannot record postures", log.Error(err))
jsonx.RenderInternalServerError(w)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) handleUnenroll(w http.ResponseWriter, r *http.Request) {
defer func() { _ = r.Body.Close() }()
dev := deviceFromContext(r.Context())
if dev == nil {
jsonx.RenderUnauthorized(w, errors.New("unauthorized"))
return
}
scope := coredata.NewScopeFromObjectID(dev.ID)
if err := h.itamSvc.UnenrollDevice(r.Context(), scope, dev.ID); err != nil {
h.logger.ErrorCtx(r.Context(), "cannot unenroll device", log.Error(err))
jsonx.RenderInternalServerError(w)
return
}
w.WriteHeader(http.StatusNoContent)
}
type ctxKey struct{ name string }
var deviceContextKey = &ctxKey{name: "device"}
func deviceFromContext(ctx context.Context) *coredata.Device {
v := ctx.Value(deviceContextKey)
d, _ := v.(*coredata.Device)
return d
}
func (h *Handler) deviceAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if auth == "" {
jsonx.RenderUnauthorized(w, errors.New("missing authorization"))
return
}
token, err := bearertoken.Parse(auth)
if err != nil {
jsonx.RenderUnauthorized(w, errors.New("invalid bearer token"))
return
}
dev, err := h.itamSvc.AuthenticateDevice(r.Context(), token)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
jsonx.RenderUnauthorized(w, errors.New("unauthorized"))
return
}
h.logger.ErrorCtx(r.Context(), "cannot authenticate device", log.Error(err))
jsonx.RenderInternalServerError(w)
return
}
ctx := contextWithDevice(r.Context(), dev)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package agent_v1
import (
"context"
"go.probo.inc/probo/pkg/coredata"
)
func contextWithDevice(ctx context.Context, device *coredata.Device) context.Context {
return context.WithValue(ctx, deviceContextKey, device)
}

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package types
import (
"encoding/json"
"errors"
"time"
"go.probo.inc/probo/pkg/coredata"
)
const (
heartbeatIntervalSeconds = 300
postureIntervalSeconds = 3600
maxPostureResultsPerRequest = 100
)
type (
HeartbeatRequest struct {
HardwareUUID string `json:"hardware_uuid"`
SerialNumber *string `json:"serial_number,omitempty"`
Hostname string `json:"hostname"`
Platform coredata.DevicePlatform `json:"platform"`
OSVersion string `json:"os_version"`
AgentVersion string `json:"agent_version"`
}
HeartbeatResponse struct {
DeviceID string `json:"device_id"`
HeartbeatSeconds int `json:"heartbeat_interval_seconds"`
PostureSeconds int `json:"posture_interval_seconds"`
ServerTime string `json:"server_time"`
}
PostureResultPayload struct {
CheckKey string `json:"check_key"`
Status coredata.DevicePostureStatus `json:"status"`
Evidence json.RawMessage `json:"evidence,omitempty"`
ObservedAt time.Time `json:"observed_at"`
}
PostureRequest struct {
Results []PostureResultPayload `json:"results"`
}
EnrollRequest struct {
Token string `json:"token"`
}
EnrollResponse struct {
APIKey string `json:"api_key"`
}
)
func (r HeartbeatRequest) Validate() error {
if r.HardwareUUID == "" {
return errors.New("hardware_uuid is required")
}
if r.Hostname == "" {
return errors.New("hostname is required")
}
if !r.Platform.IsValid() {
return errors.New("platform is invalid")
}
if r.OSVersion == "" {
return errors.New("os_version is required")
}
if r.AgentVersion == "" {
return errors.New("agent_version is required")
}
return nil
}
func NewHeartbeatResponse(device *coredata.Device) *HeartbeatResponse {
return &HeartbeatResponse{
DeviceID: device.ID.String(),
HeartbeatSeconds: heartbeatIntervalSeconds,
PostureSeconds: postureIntervalSeconds,
ServerTime: time.Now().UTC().Format(time.RFC3339),
}
}
func (r PostureRequest) Validate() error {
if len(r.Results) > maxPostureResultsPerRequest {
return errors.New("too many results")
}
for _, result := range r.Results {
if result.CheckKey == "" {
return errors.New("check_key is required")
}
if !result.Status.IsValid() {
return errors.New("status is invalid")
}
}
return nil
}
func (r EnrollRequest) Validate() error {
if r.Token == "" {
return errors.New("token is required")
}
return nil
}

View File

@@ -44,11 +44,13 @@ import (
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/geoloc"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/itam"
"go.probo.inc/probo/pkg/mailman"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/resourcealias"
"go.probo.inc/probo/pkg/riskmanagement"
"go.probo.inc/probo/pkg/securecookie"
agent_v1 "go.probo.inc/probo/pkg/server/api/agent/v1"
connect_v1 "go.probo.inc/probo/pkg/server/api/connect/v1"
console_v1 "go.probo.inc/probo/pkg/server/api/console/v1"
cookiebanner_v1 "go.probo.inc/probo/pkg/server/api/cookiebanner/v1"
@@ -80,6 +82,7 @@ type (
Geoloc *geoloc.Service
ThirdParty *thirdparty.Service
RiskManagement *riskmanagement.Service
ITAM *itam.Service
Cookie securecookie.Config
TokenSecret string
ConnectorRegistry *connector.ConnectorRegistry
@@ -104,6 +107,7 @@ type (
mcpHandler http.Handler
slackHandler http.Handler
connectHandler http.Handler
agentHandler http.Handler
}
)
@@ -111,6 +115,7 @@ var (
ErrMissingProboService = errors.New("server configuration requires a valid probo.Service instance")
ErrMissingIAMService = errors.New("server configuration requires a valid iam.Service instance")
ErrMissingSlackService = errors.New("server configuration requires a valid slack.Service instance")
ErrMissingITAMService = errors.New("server configuration requires a valid itam.Service instance")
)
func methodNotAllowed(w http.ResponseWriter, r *http.Request) {
@@ -150,6 +155,10 @@ func NewServer(cfg Config) (*Server, error) {
return nil, ErrMissingSlackService
}
if cfg.ITAM == nil {
return nil, ErrMissingITAMService
}
csrf := http.NewCrossOriginProtection()
for _, origin := range cfg.AllowedOrigins {
if err := csrf.AddTrustedOrigin(origin); err != nil {
@@ -209,6 +218,7 @@ func NewServer(cfg Config) (*Server, error) {
cfg.ThirdParty,
cfg.RiskManagement,
cfg.GraphQLLimits,
cfg.ITAM,
),
cookieBannerHandler: cookiebanner_v1.NewMux(
cfg.Logger.Named("cookiebanner.v1"),
@@ -261,6 +271,10 @@ func NewServer(cfg Config) (*Server, error) {
},
cfg.GraphQLLimits,
),
agentHandler: agent_v1.NewMux(
cfg.Logger.Named("agent.v1"),
cfg.ITAM,
),
}, nil
}
@@ -293,6 +307,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// list that applies to console/connect routes.
router.Mount("/cookie-banner/v1", http.StripPrefix("/cookie-banner/v1", s.cookieBannerHandler))
// Agent API should never be called from a browser; mount it outside
// to avoid CORS headers being set on it.
router.Mount("/agent/v1", http.StripPrefix("/agent/v1", s.agentHandler))
router.Group(func(r chi.Router) {
r.Use(cors.Handler(corsOpts))
r.Mount("/console/v1", http.StripPrefix("/console/v1", s.consoleHandler))

View File

@@ -17,6 +17,7 @@ import (
"go.probo.inc/probo/pkg/complianceportal/management"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/itam"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
@@ -394,6 +395,16 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
return types.NewAgentRun(run), nil
}
case coredata.DeviceEntityType:
action = itam.ActionDeviceGet
loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) {
device, err := r.itam.GetDevice(ctx, scope, id)
if err != nil {
return nil, err
}
return types.NewDevice(device), nil
}
case coredata.AccessReviewCampaignEntityType:
action = accessreview.ActionCampaignGet
loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) {

View File

@@ -0,0 +1,239 @@
package console_v1
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.94
import (
"context"
"errors"
"github.com/vikstrous/dataloadgen"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/itam"
"go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/api/authz"
"go.probo.inc/probo/pkg/server/api/console/v1/dataloader"
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
"go.probo.inc/probo/pkg/server/api/console/v1/types"
"go.probo.inc/probo/pkg/server/gqlutils"
)
// Owner is the resolver for the owner field.
func (r *deviceResolver) Owner(ctx context.Context, obj *types.Device) (*types.Profile, error) {
if obj.Owner == nil {
return nil, nil
}
if _, err := r.authorize(ctx, obj.Owner.ID, iam.ActionMembershipProfileGet); err != nil {
return nil, err
}
loaders := dataloader.FromContext(ctx)
owner, err := loaders.Profile.Load(ctx, obj.Owner.ID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot get device owner profile", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewProfile(owner), nil
}
// LatestPostures is the resolver for the Device.latestPostures field.
func (r *deviceResolver) LatestPostures(ctx context.Context, obj *types.Device) ([]*types.DevicePosture, error) {
scope, err := r.authorize(ctx, obj.ID, itam.ActionDevicePostureList)
if err != nil {
return nil, err
}
postures, err := r.itam.GetLatestPostures(ctx, scope, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot load latest device postures", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewDevicePostures(postures), nil
}
// 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
}
switch obj.Resolver.(type) {
case *organizationResolver:
count, err := r.itam.CountForOrganizationID(ctx, scope, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count devices", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
return 0, gqlutils.Internal(ctx)
}
// EnrollDevice is the resolver for the enrollDevice field.
func (r *mutationResolver) EnrollDevice(ctx context.Context, input types.EnrollDeviceInput) (*types.CreateDevicePayload, error) {
identity := authn.IdentityFromContext(ctx)
scope, err := r.authorize(
ctx,
input.OrganizationID,
itam.ActionDeviceEnroll,
authz.WithSkipAssumptionCheck(),
)
if err != nil {
return nil, err
}
result, err := r.itam.EnrollDevice(
ctx, scope,
itam.EnrollDeviceRequest{
OrganizationID: input.OrganizationID,
IdentityID: identity.ID,
},
)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot enroll device", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
urls, err := buildEnrollmentURLs(r.baseURL, result.EnrollmentToken)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot build enrollment URLs", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.CreateDevicePayload{
Device: types.NewDevice(result.Device),
EnrollmentToken: result.EnrollmentToken,
ServerURL: urls.ServerURL,
EnrollmentURL: urls.EnrollmentURL,
}, nil
}
// CreateDevice is the resolver for the createDevice field.
func (r *mutationResolver) CreateDevice(ctx context.Context, input types.CreateDeviceInput) (*types.CreateDevicePayload, error) {
scope, err := r.authorize(ctx, input.OrganizationID, itam.ActionDeviceCreate)
if err != nil {
return nil, err
}
result, err := r.itam.CreateDevice(
ctx, scope,
itam.CreateDeviceRequest{
OrganizationID: input.OrganizationID,
OwnerID: input.OwnerID,
},
)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot create device", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
urls, err := buildEnrollmentURLs(r.baseURL, result.EnrollmentToken)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot build enrollment URLs", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.CreateDevicePayload{
Device: types.NewDevice(result.Device),
EnrollmentToken: result.EnrollmentToken,
ServerURL: urls.ServerURL,
EnrollmentURL: urls.EnrollmentURL,
}, nil
}
// RevokeDevice is the resolver for the revokeDevice field.
func (r *mutationResolver) RevokeDevice(ctx context.Context, input types.RevokeDeviceInput) (*types.RevokeDevicePayload, error) {
scope, err := r.authorize(ctx, input.DeviceID, itam.ActionDeviceRevoke)
if err != nil {
return nil, err
}
d, err := r.itam.RevokeDevice(ctx, scope, input.DeviceID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot revoke device", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.RevokeDevicePayload{Device: types.NewDevice(d)}, nil
}
// SetDeviceOwner is the resolver for the setDeviceOwner field.
func (r *mutationResolver) SetDeviceOwner(ctx context.Context, input types.SetDeviceOwnerInput) (*types.SetDeviceOwnerPayload, error) {
scope, err := r.authorize(ctx, input.DeviceID, itam.ActionDeviceAssignOwner)
if err != nil {
return nil, err
}
d, err := r.itam.SetDeviceOwner(ctx, scope, input.DeviceID, input.OwnerID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot set device owner", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.SetDeviceOwnerPayload{Device: types.NewDevice(d)}, nil
}
// Device returns schema.DeviceResolver implementation.
func (r *Resolver) Device() schema.DeviceResolver { return &deviceResolver{r} }
// DeviceConnection returns schema.DeviceConnectionResolver implementation.
func (r *Resolver) DeviceConnection() schema.DeviceConnectionResolver {
return &deviceConnectionResolver{r}
}
type (
deviceResolver struct{ *Resolver }
deviceConnectionResolver struct{ *Resolver }
)

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package console_v1
import (
"fmt"
"net/url"
"go.probo.inc/probo/pkg/baseurl"
)
// enrollmentURLs holds the public API origin and probo:// deep link issued
// when a device enrollment token is created.
type enrollmentURLs struct {
ServerURL string
EnrollmentURL string
}
// buildEnrollmentURLs derives the agent server origin and deep link from the
// deployment base URL and a one-shot enrollment token.
func buildEnrollmentURLs(baseURL *baseurl.BaseURL, enrollmentToken string) (enrollmentURLs, error) {
if baseURL == nil {
return enrollmentURLs{}, fmt.Errorf("base URL is required")
}
if enrollmentToken == "" {
return enrollmentURLs{}, fmt.Errorf("enrollment token is required")
}
serverURL := (&url.URL{
Scheme: baseURL.Scheme(),
Host: baseURL.Host(),
}).String()
enrollURL := &url.URL{
Scheme: "probo",
Host: "enroll",
}
query := enrollURL.Query()
query.Set("server", serverURL)
query.Set("token", enrollmentToken)
enrollURL.RawQuery = query.Encode()
return enrollmentURLs{
ServerURL: serverURL,
EnrollmentURL: enrollURL.String(),
}, nil
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package console_v1
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/baseurl"
)
func TestBuildEnrollmentURLs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
baseURL string
token string
wantServerURL string
wantErrContains string
}{
{
name: "strips path from base URL",
baseURL: "https://us.probo.com/console",
token: "secret-token",
wantServerURL: "https://us.probo.com",
},
{
name: "keeps non-default port",
baseURL: "http://127.0.0.1:8080/api",
token: "tok",
wantServerURL: "http://127.0.0.1:8080",
},
{
name: "nil base URL",
token: "tok",
wantErrContains: "base URL is required",
},
{
name: "empty token",
baseURL: "https://us.probo.com",
wantErrContains: "enrollment token is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var base *baseurl.BaseURL
if tt.baseURL != "" {
parsed, err := baseurl.Parse(tt.baseURL)
require.NoError(t, err)
base = parsed
}
got, err := buildEnrollmentURLs(base, tt.token)
if tt.wantErrContains != "" {
require.Error(t, err)
assert.ErrorContains(t, err, tt.wantErrContains)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantServerURL, got.ServerURL)
parsed, err := url.Parse(got.EnrollmentURL)
require.NoError(t, err)
assert.Equal(t, "probo", parsed.Scheme)
assert.Equal(t, "enroll", parsed.Host)
assert.Equal(t, tt.wantServerURL, parsed.Query().Get("server"))
assert.Equal(t, tt.token, parsed.Query().Get("token"))
})
}
}

View File

@@ -0,0 +1,165 @@
# Copyright (c) 2026 Probo Inc <hello@probo.com>.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
enum DevicePlatform
@goModel(model: "go.probo.inc/probo/pkg/coredata.DevicePlatform") {
DARWIN
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePlatformDarwin")
LINUX
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePlatformLinux")
FREEBSD
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePlatformFreeBSD")
WINDOWS
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePlatformWindows")
}
enum DeviceState
@goModel(model: "go.probo.inc/probo/pkg/coredata.DeviceState") {
PENDING
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DeviceStatePending")
ACTIVE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DeviceStateActive")
REVOKED
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DeviceStateRevoked")
}
enum DevicePostureStatus
@goModel(
model: "go.probo.inc/probo/pkg/coredata.DevicePostureStatus"
) {
PASS
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePostureStatusPass")
FAIL
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePostureStatusFail")
UNKNOWN
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePostureStatusUnknown")
NOT_APPLICABLE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DevicePostureStatusNotApplicable"
)
}
enum DeviceOrderField
@goModel(model: "go.probo.inc/probo/pkg/coredata.DeviceOrderField") {
CREATED_AT
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DeviceOrderFieldCreatedAt")
UPDATED_AT
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DeviceOrderFieldUpdatedAt")
HOSTNAME
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DeviceOrderFieldHostname")
LAST_SEEN_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DeviceOrderFieldLastSeenAt"
)
}
input DeviceOrder
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DeviceOrderBy"
) {
direction: OrderDirection!
field: DeviceOrderField!
}
type Device implements Node {
id: ID!
state: DeviceState!
hostname: String
serialNumber: String
hardwareUuid: String
platform: DevicePlatform
osVersion: String
agentVersion: String
enrolledAt: Datetime
lastSeenAt: Datetime
revokedAt: Datetime
createdAt: Datetime!
updatedAt: Datetime!
owner: Profile @goField(forceResolver: true)
latestPostures: [DevicePosture!]! @goField(forceResolver: true)
}
type DevicePosture implements Node {
id: ID!
deviceId: ID!
checkKey: String!
status: DevicePostureStatus!
observedAt: Datetime!
}
type DeviceConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DeviceConnection"
) {
edges: [DeviceEdge!]!
pageInfo: PageInfo!
totalCount: Int! @goField(forceResolver: true)
}
type DeviceEdge {
cursor: CursorKey!
node: Device!
}
type CreateDevicePayload {
device: Device!
# enrollmentToken is shown ONCE; exchange via agent REST /enroll.
enrollmentToken: String!
# serverUrl is the public API origin for agent --server / deep-link server=.
serverUrl: String!
# enrollmentUrl is probo://enroll?server=...&token=... for the desktop agent.
enrollmentUrl: String!
}
type RevokeDevicePayload {
device: Device!
}
type SetDeviceOwnerPayload {
device: Device!
}
input EnrollDeviceInput {
organizationId: ID!
}
input CreateDeviceInput {
organizationId: ID!
ownerId: ID
}
input RevokeDeviceInput {
deviceId: ID!
}
input SetDeviceOwnerInput {
deviceId: ID!
ownerId: ID
}
extend type Mutation {
enrollDevice(input: EnrollDeviceInput!): CreateDevicePayload!
createDevice(input: CreateDeviceInput!): CreateDevicePayload!
revokeDevice(input: RevokeDeviceInput!): RevokeDevicePayload!
setDeviceOwner(
input: SetDeviceOwnerInput!
): SetDeviceOwnerPayload!
}

View File

@@ -342,6 +342,14 @@ type Organization implements Node {
thirdPartiesDocument: Document @goField(forceResolver: true)
devices(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: DeviceOrder
): DeviceConnection! @goField(forceResolver: true)
webhookSubscriptions(
first: Int
after: CursorKey

View File

@@ -22,4 +22,13 @@ type Viewer {
): EmployeeDocumentConnection! @goField(forceResolver: true)
approvableDocument(id: ID!): EmployeeDocument @goField(forceResolver: true)
enrolledDevices(
organizationId: ID!
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: DeviceOrder
): DeviceConnection! @goField(forceResolver: true)
}

View File

@@ -35,6 +35,7 @@ import (
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/itam"
"go.probo.inc/probo/pkg/mailman"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/resourcealias"
@@ -67,6 +68,7 @@ func NewGraphQLHandler(
fileManagerSvc *filemanager.Service,
baseURL *baseurl.BaseURL,
limits gqlutils.Limits,
itamSvc *itam.Service,
) http.Handler {
config := schema.Config{
Resolvers: &Resolver{
@@ -90,6 +92,7 @@ func NewGraphQLHandler(
tokenSecret: tokenSecret,
fileManager: fileManagerSvc,
baseURL: baseURL,
itam: itamSvc,
logger: logger,
},
}

View File

@@ -17,6 +17,7 @@ import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/itam"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
@@ -1307,6 +1308,35 @@ func (r *organizationResolver) ThirdPartiesDocument(ctx context.Context, obj *ty
return types.NewDocument(document), nil
}
// Devices is the resolver for the devices field.
func (r *organizationResolver) Devices(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DeviceOrderBy) (*types.DeviceConnection, error) {
scope, err := r.authorize(ctx, obj.ID, itam.ActionDeviceList)
if err != nil {
return nil, err
}
pageOrderBy := page.OrderBy[coredata.DeviceOrderField]{
Field: coredata.DeviceOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.DeviceOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
devicesPage, err := r.itam.ListForOrganizationID(ctx, scope, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list organization devices", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewDeviceConnection(devicesPage, r, obj.ID), nil
}
// WebhookSubscriptions is the resolver for the webhookSubscriptions field.
func (r *organizationResolver) WebhookSubscriptions(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.WebhookSubscriptionOrderBy) (*types.WebhookSubscriptionConnection, error) {
scope, err := r.authorize(ctx, obj.ID, probo.ActionWebhookSubscriptionList)

View File

@@ -45,6 +45,7 @@ import (
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/itam"
"go.probo.inc/probo/pkg/mailman"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/resourcealias"
@@ -77,6 +78,7 @@ type (
providerRegistry *provider.Registry
riskManagement *riskmanagement.Service
thirdParty *thirdparty.Service
itam *itam.Service
logger *log.Logger
fileManager *filemanager.Service
baseURL *baseurl.BaseURL
@@ -107,6 +109,7 @@ func NewMux(
thirdPartySvc *thirdparty.Service,
riskManagementSvc *riskmanagement.Service,
graphqlLimits gqlutils.Limits,
itamSvc *itam.Service,
) *chi.Mux {
r := chi.NewMux()
@@ -133,6 +136,7 @@ func NewMux(
fileManagerSvc,
baseURL,
graphqlLimits,
itamSvc,
)
r.Group(func(r chi.Router) {
@@ -506,5 +510,6 @@ func isValidPagerDutySubdomain(s string) bool {
func (r *Resolver) Permission(ctx context.Context, obj types.Node, action string) (bool, error) {
_, err := r.authorize(ctx, obj.GetID(), action, authz.WithDryRun())
return err == nil, nil
}

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
DeviceOrderBy OrderBy[coredata.DeviceOrderField]
DeviceConnection struct {
TotalCount int
Edges []*DeviceEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
OwnerID *gid.GID
}
)
func NewDeviceConnection(
p *page.Page[*coredata.Device, coredata.DeviceOrderField],
parentType any,
parentID gid.GID,
) *DeviceConnection {
edges := make([]*DeviceEdge, len(p.Data))
for i := range edges {
edges[i] = NewDeviceEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &DeviceConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
Resolver: parentType,
ParentID: parentID,
}
}
func NewOwnedDeviceConnection(
p *page.Page[*coredata.Device, coredata.DeviceOrderField],
parentType any,
parentID gid.GID,
ownerID gid.GID,
) *DeviceConnection {
conn := NewDeviceConnection(p, parentType, parentID)
conn.OwnerID = &ownerID
return conn
}
func NewDeviceEdge(d *coredata.Device, orderBy coredata.DeviceOrderField) *DeviceEdge {
return &DeviceEdge{
Cursor: d.CursorKey(orderBy),
Node: NewDevice(d),
}
}
func NewDevice(d *coredata.Device) *Device {
device := &Device{
ID: d.ID,
State: d.State,
Hostname: d.Hostname,
SerialNumber: d.SerialNumber,
HardwareUUID: d.HardwareUUID,
Platform: d.Platform,
OsVersion: d.OSVersion,
AgentVersion: d.AgentVersion,
EnrolledAt: d.EnrolledAt,
LastSeenAt: d.LastSeenAt,
RevokedAt: d.RevokedAt,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
}
if d.OwnerID != nil {
device.Owner = &Profile{ID: *d.OwnerID}
}
return device
}
func NewDevicePosture(p *coredata.DevicePosture) *DevicePosture {
return &DevicePosture{
ID: p.ID,
DeviceID: p.DeviceID,
CheckKey: p.CheckKey,
Status: p.Status,
ObservedAt: p.ObservedAt,
}
}
func NewDevicePostures(ps coredata.DevicePostures) []*DevicePosture {
out := make([]*DevicePosture, len(ps))
for i, p := range ps {
out[i] = NewDevicePosture(p)
}
return out
}

View File

@@ -12,6 +12,8 @@ import (
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/itam"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/server/api/authn"
@@ -180,6 +182,54 @@ func (r *viewerResolver) ApprovableDocument(ctx context.Context, obj *types.View
}, nil
}
// 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) {
scope, err := r.authorize(ctx, organizationID, itam.ActionEmployeeDeviceList)
if err != nil {
return nil, err
}
pageOrderBy := page.OrderBy[coredata.DeviceOrderField]{
Field: coredata.DeviceOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.DeviceOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
identity := authn.IdentityFromContext(ctx)
profile, err := r.iam.OrganizationService.GetProfileForIdentityAndOrganization(
ctx,
identity.ID,
organizationID,
)
if err != nil {
if _, ok := errors.AsType[*iam.ErrProfileNotFound](err); ok {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot get enrolled devices owner profile", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
devicesPage, err := r.itam.ListForOrganizationIDAndOwnerID(
ctx, scope, organizationID, profile.ID, cursor,
)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list enrolled devices", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewOwnedDeviceConnection(devicesPage, r, organizationID, profile.ID), nil
}
// Viewer returns schema.ViewerResolver implementation.
func (r *Resolver) Viewer() schema.ViewerResolver { return &viewerResolver{r} }