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:
@@ -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) {
|
||||
|
||||
239
pkg/server/api/console/v1/device_resolvers.go
Normal file
239
pkg/server/api/console/v1/device_resolvers.go
Normal 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 }
|
||||
)
|
||||
66
pkg/server/api/console/v1/enrollment_url.go
Normal file
66
pkg/server/api/console/v1/enrollment_url.go
Normal 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
|
||||
}
|
||||
98
pkg/server/api/console/v1/enrollment_url_test.go
Normal file
98
pkg/server/api/console/v1/enrollment_url_test.go
Normal 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"))
|
||||
})
|
||||
}
|
||||
}
|
||||
165
pkg/server/api/console/v1/graphql/device.graphql
Normal file
165
pkg/server/api/console/v1/graphql/device.graphql
Normal 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!
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
120
pkg/server/api/console/v1/types/device.go
Normal file
120
pkg/server/api/console/v1/types/device.go
Normal 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
|
||||
}
|
||||
@@ -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} }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user