Expose ITAM devices on MCP, CLI, and n8n

Devices were only available through GraphQL and the agent API. Add
list/get/revoke/delete/set-owner across MCP, prb, and n8n, with latest
postures nested on list and get responses.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-07-30 18:20:09 +02:00
parent 36e038f5ff
commit 62e65eccda
26 changed files with 2548 additions and 3 deletions

225
e2e/mcp/device_test.go Normal file
View File

@@ -0,0 +1,225 @@
// 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 mcp_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/e2e/internal/factory"
"go.probo.inc/probo/e2e/internal/testutil"
)
const createDeviceMutation = `
mutation CreateDevice($input: CreateDeviceInput!) {
createDevice(input: $input) {
device { id state }
}
}
`
type mcpDevice struct {
ID string `json:"id"`
OrganizationID string `json:"organization_id"`
State string `json:"state"`
OwnerID *string `json:"owner_id"`
LatestPostures []struct {
ID string `json:"id"`
CheckKey string `json:"check_key"`
Status string `json:"status"`
} `json:"latest_postures"`
}
func createDeviceViaGraphQL(t *testing.T, owner *testutil.Client, orgID string) string {
t.Helper()
var result struct {
CreateDevice struct {
Device struct {
ID string `json:"id"`
State string `json:"state"`
} `json:"device"`
} `json:"createDevice"`
}
owner.MustExecute(createDeviceMutation, map[string]any{
"input": map[string]any{
"organizationId": orgID,
},
}, &result)
require.NotEmpty(t, result.CreateDevice.Device.ID)
return result.CreateDevice.Device.ID
}
func TestMCP_Device_Lifecycle(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
orgID := owner.GetOrganizationID().String()
profileID := factory.CreateUser(owner)
deviceID := createDeviceViaGraphQL(t, owner, orgID)
// Get
var getResult struct {
Device mcpDevice `json:"device"`
}
mc.CallToolInto("getDevice", map[string]any{
"id": deviceID,
}, &getResult)
assert.Equal(t, deviceID, getResult.Device.ID)
assert.Equal(t, "PENDING", getResult.Device.State)
assert.NotNil(t, getResult.Device.LatestPostures)
assert.Empty(t, getResult.Device.LatestPostures)
// List
var listResult struct {
Devices []mcpDevice `json:"devices"`
}
mc.CallToolInto("listDevices", map[string]any{
"organization_id": orgID,
}, &listResult)
require.NotEmpty(t, listResult.Devices)
found := false
for _, d := range listResult.Devices {
if d.ID == deviceID {
found = true
assert.NotNil(t, d.LatestPostures)
break
}
}
assert.True(t, found, "created device should appear in listDevices")
// Set owner
var setOwnerResult struct {
Device mcpDevice `json:"device"`
}
mc.CallToolInto("setDeviceOwner", map[string]any{
"id": deviceID,
"owner_id": profileID,
}, &setOwnerResult)
require.NotNil(t, setOwnerResult.Device.OwnerID)
assert.Equal(t, profileID, *setOwnerResult.Device.OwnerID)
// Clear owner
mc.CallToolInto("setDeviceOwner", map[string]any{
"id": deviceID,
"owner_id": nil,
}, &setOwnerResult)
assert.Nil(t, setOwnerResult.Device.OwnerID)
// Revoke
var revokeResult struct {
Device mcpDevice `json:"device"`
}
mc.CallToolInto("revokeDevice", map[string]any{
"id": deviceID,
}, &revokeResult)
assert.Equal(t, "REVOKED", revokeResult.Device.State)
// Delete
var deleteResult struct {
DeletedDeviceID string `json:"deleted_device_id"`
}
mc.CallToolInto("deleteDevice", map[string]any{
"id": deviceID,
}, &deleteResult)
assert.Equal(t, deviceID, deleteResult.DeletedDeviceID)
msg := mc.CallToolExpectToolError("getDevice", map[string]any{
"id": deviceID,
})
assert.Equal(t, "resource not found", msg)
}
func TestMCP_Device_CannotDeletePending(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
orgID := owner.GetOrganizationID().String()
deviceID := createDeviceViaGraphQL(t, owner, orgID)
msg := mc.CallToolExpectToolError("deleteDevice", map[string]any{
"id": deviceID,
})
assert.Equal(t, "device cannot be deleted", msg)
}
func TestMCP_Device_PermissionDenied(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
orgID := owner.GetOrganizationID().String()
deviceID := createDeviceViaGraphQL(t, owner, orgID)
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
viewerMC := testutil.NewMCPClient(t, viewer)
msg := viewerMC.CallToolExpectToolError("revokeDevice", map[string]any{
"id": deviceID,
})
assert.Contains(t, msg, "permission denied")
}
func TestMCP_Device_EmployeeCannotIncludePostures(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
ownerMC := testutil.NewMCPClient(t, owner)
orgID := owner.GetOrganizationID().String()
employee := testutil.NewClientInOrg(t, testutil.RoleEmployee, owner)
employeeMC := testutil.NewMCPClient(t, employee)
employeeProfileID := employee.GetProfileID().String()
deviceID := createDeviceViaGraphQL(t, owner, orgID)
var setOwnerResult struct {
Device mcpDevice `json:"device"`
}
ownerMC.CallToolInto("setDeviceOwner", map[string]any{
"id": deviceID,
"owner_id": employeeProfileID,
}, &setOwnerResult)
require.NotNil(t, setOwnerResult.Device.OwnerID)
assert.Equal(t, employeeProfileID, *setOwnerResult.Device.OwnerID)
var getResult struct {
Device mcpDevice `json:"device"`
}
employeeMC.CallToolInto("getDevice", map[string]any{
"id": deviceID,
}, &getResult)
assert.Equal(t, deviceID, getResult.Device.ID)
assert.NotNil(t, getResult.Device.LatestPostures)
assert.Empty(t, getResult.Device.LatestPostures)
msg := employeeMC.CallToolExpectToolError("getDevice", map[string]any{
"id": deviceID,
"include_postures": true,
})
assert.Contains(t, msg, "permission denied")
}

View File

@@ -130,7 +130,7 @@ This pattern works well for daily compliance standups or overdue-task digests.
The **Probo** node exposes operations across the platform, including:
Access Review, Asset, Audit, Audit Log, Control, Cookie Banner, Cookie Category, Cookie Consent Record, Data, Document, DPIA, Evidence, Finding, Framework, Measure, Obligation, Organization, Processing Activity, Risk, Task, Third Party, Trust Center, User, Vendor, and more.
Access Review, Asset, Audit, Audit Log, Control, Cookie Banner, Cookie Category, Cookie Consent Record, Data, Device, Document, DPIA, Evidence, Finding, Framework, Measure, Obligation, Organization, Processing Activity, Risk, Task, Third Party, Trust Center, User, Vendor, and more.
Use the **Execute** resource to run custom GraphQL queries or mutations when a dedicated operation is not available.

View File

@@ -128,6 +128,11 @@ export class Probo implements INodeType {
value: 'datum',
description: 'Manage data',
},
{
name: 'Device',
value: 'device',
description: 'Manage ITAM devices',
},
{
name: 'Document',
value: 'document',

View File

@@ -0,0 +1,63 @@
// 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.
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { proboApiRequest } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: 'Device ID',
name: 'deviceId',
type: 'string',
displayOptions: {
show: {
resource: ['device'],
operation: ['delete'],
},
},
default: '',
description: 'The ID of the revoked device to delete',
required: true,
},
];
export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const deviceId = this.getNodeParameter('deviceId', itemIndex) as string;
const query = `
mutation DeleteDevice($input: DeleteDeviceInput!) {
deleteDevice(input: $input) {
deletedDeviceId
}
}
`;
const responseData = await proboApiRequest.call(this, query, {
input: { deviceId },
});
return {
json: responseData,
pairedItem: { item: itemIndex },
};
}

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.
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { proboApiRequest } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: 'Device ID',
name: 'deviceId',
type: 'string',
displayOptions: {
show: {
resource: ['device'],
operation: ['get'],
},
},
default: '',
description: 'The ID of the device',
required: true,
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
displayOptions: {
show: {
resource: ['device'],
operation: ['get'],
},
},
options: [
{
displayName: 'Include Owner',
name: 'includeOwner',
type: 'boolean',
default: false,
description: 'Whether to include owner details in the response',
},
{
displayName: 'Include Postures',
name: 'includePostures',
type: 'boolean',
default: false,
description: 'Whether to include latest posture check results in the response',
},
],
},
];
export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const deviceId = this.getNodeParameter('deviceId', itemIndex) as string;
const options = this.getNodeParameter('options', itemIndex, {}) as {
includeOwner?: boolean;
includePostures?: boolean;
};
const ownerFragment = options.includeOwner
? `owner {
id
fullName
}`
: '';
const latestPosturesFragment = options.includePostures
? `latestPostures {
id
checkKey
status
value {
kind
text
number
}
observedAt
}`
: '';
const query = `
query GetDevice($deviceId: ID!) {
node(id: $deviceId) {
... on Device {
id
state
hostname
platform
osVersion
agentVersion
serialNumber
hardwareUuid
enrolledAt
lastSeenAt
revokedAt
createdAt
updatedAt
${ownerFragment}
${latestPosturesFragment}
}
}
}
`;
const responseData = await proboApiRequest.call(this, query, { deviceId });
return {
json: responseData,
pairedItem: { item: itemIndex },
};
}

View File

@@ -0,0 +1,183 @@
// 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.
import type { INodeProperties, IExecuteFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow';
import { proboApiRequestAllItems } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: 'Organization ID',
name: 'organizationId',
type: 'string',
displayOptions: {
show: {
resource: ['device'],
operation: ['getAll'],
},
},
default: '',
description: 'The ID of the organization',
required: true,
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
resource: ['device'],
operation: ['getAll'],
},
},
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
resource: ['device'],
operation: ['getAll'],
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
},
default: 50,
description: 'Max number of results to return',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
displayOptions: {
show: {
resource: ['device'],
operation: ['getAll'],
},
},
options: [
{
displayName: 'Include Owner',
name: 'includeOwner',
type: 'boolean',
default: false,
description: 'Whether to include owner details in the response',
},
{
displayName: 'Include Postures',
name: 'includePostures',
type: 'boolean',
default: false,
description: 'Whether to include latest posture check results in the response',
},
],
},
];
export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const organizationId = this.getNodeParameter('organizationId', itemIndex) as string;
const returnAll = this.getNodeParameter('returnAll', itemIndex) as boolean;
const limit = this.getNodeParameter('limit', itemIndex, 50) as number;
const options = this.getNodeParameter('options', itemIndex, {}) as {
includeOwner?: boolean;
includePostures?: boolean;
};
const ownerFragment = options.includeOwner
? `owner {
id
fullName
}`
: '';
const latestPosturesFragment = options.includePostures
? `latestPostures {
id
checkKey
status
value {
kind
text
number
}
observedAt
}`
: '';
const query = `
query GetDevices($organizationId: ID!, $first: Int, $after: CursorKey) {
node(id: $organizationId) {
... on Organization {
devices(first: $first, after: $after) {
edges {
node {
id
state
hostname
platform
osVersion
agentVersion
serialNumber
lastSeenAt
enrolledAt
revokedAt
createdAt
updatedAt
${ownerFragment}
${latestPosturesFragment}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
`;
const devices = await proboApiRequestAllItems.call(
this,
query,
{ organizationId },
(response) => {
const data = response?.data as IDataObject | undefined;
const node = data?.node as IDataObject | undefined;
return node?.devices as IDataObject | undefined;
},
returnAll,
limit,
);
return {
json: { devices },
pairedItem: { item: itemIndex },
};
}

View File

@@ -0,0 +1,86 @@
// 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.
import type { INodeProperties } from 'n8n-workflow';
import * as deleteOp from './delete.operation';
import * as getOp from './get.operation';
import * as getAllOp from './getAll.operation';
import * as revokeOp from './revoke.operation';
import * as setOwnerOp from './setOwner.operation';
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['device'],
},
},
options: [
{
name: 'Delete',
value: 'delete',
description: 'Delete a revoked ITAM device',
action: 'Delete a device',
},
{
name: 'Get',
value: 'get',
description: 'Get an ITAM device',
action: 'Get a device',
},
{
name: 'Get Many',
value: 'getAll',
description: 'Get many ITAM devices',
action: 'Get many devices',
},
{
name: 'Revoke',
value: 'revoke',
description: 'Revoke an ITAM device enrollment',
action: 'Revoke a device',
},
{
name: 'Set Owner',
value: 'setOwner',
description: 'Set or clear the owner of an ITAM device',
action: 'Set device owner',
},
],
default: 'getAll',
},
...deleteOp.description,
...getOp.description,
...getAllOp.description,
...revokeOp.description,
...setOwnerOp.description,
];
export {
deleteOp as delete,
getOp as get,
getAllOp as getAll,
revokeOp as revoke,
setOwnerOp as setOwner,
};

View File

@@ -0,0 +1,67 @@
// 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.
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { proboApiRequest } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: 'Device ID',
name: 'deviceId',
type: 'string',
displayOptions: {
show: {
resource: ['device'],
operation: ['revoke'],
},
},
default: '',
description: 'The ID of the device to revoke',
required: true,
},
];
export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const deviceId = this.getNodeParameter('deviceId', itemIndex) as string;
const query = `
mutation RevokeDevice($input: RevokeDeviceInput!) {
revokeDevice(input: $input) {
device {
id
state
revokedAt
}
}
}
`;
const responseData = await proboApiRequest.call(this, query, {
input: { deviceId },
});
return {
json: responseData,
pairedItem: { item: itemIndex },
};
}

View File

@@ -0,0 +1,105 @@
// 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.
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { proboApiRequest } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: 'Device ID',
name: 'deviceId',
type: 'string',
displayOptions: {
show: {
resource: ['device'],
operation: ['setOwner'],
},
},
default: '',
description: 'The ID of the device',
required: true,
},
{
displayName: 'Clear Owner',
name: 'clearOwner',
type: 'boolean',
displayOptions: {
show: {
resource: ['device'],
operation: ['setOwner'],
},
},
default: false,
description: 'Whether to clear the device owner instead of assigning one',
},
{
displayName: 'Owner ID',
name: 'ownerId',
type: 'string',
displayOptions: {
show: {
resource: ['device'],
operation: ['setOwner'],
clearOwner: [false],
},
},
default: '',
description: 'The profile ID of the owner',
required: true,
},
];
export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const deviceId = this.getNodeParameter('deviceId', itemIndex) as string;
const clearOwner = this.getNodeParameter('clearOwner', itemIndex) as boolean;
const input: { deviceId: string; ownerId: string | null } = {
deviceId,
ownerId: null,
};
if (!clearOwner) {
input.ownerId = this.getNodeParameter('ownerId', itemIndex) as string;
}
const query = `
mutation SetDeviceOwner($input: SetDeviceOwnerInput!) {
setDeviceOwner(input: $input) {
device {
id
owner {
id
fullName
}
}
}
}
`;
const responseData = await proboApiRequest.call(this, query, { input });
return {
json: responseData,
pairedItem: { item: itemIndex },
};
}

View File

@@ -29,6 +29,7 @@ import * as cookieCategory from './cookieCategory';
import * as cookieConsentRecord from './cookieConsentRecord';
import * as trackerPattern from './trackerPattern';
import * as datum from './datum';
import * as device from './device';
import * as document from './document';
import * as dpia from './dpia';
import * as evidence from './evidence';
@@ -73,6 +74,7 @@ export const resources: Record<string, ResourceModule> = {
cookieConsentRecord: cookieConsentRecord as ResourceModule,
trackerPattern: trackerPattern as ResourceModule,
datum: datum as ResourceModule,
device: device as ResourceModule,
document: document as ResourceModule,
dpia: dpia as ResourceModule,
evidence: evidence as ResourceModule,

View File

@@ -0,0 +1,114 @@
// 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 delete
import (
"fmt"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const deleteMutation = `
mutation($input: DeleteDeviceInput!) {
deleteDevice(input: $input) {
deletedDeviceId
}
}
`
func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
var flagYes bool
cmd := &cobra.Command{
Use: "delete <id>",
Short: "Delete a revoked ITAM device",
Example: ` # Revoke a device, then delete it
prb device revoke <device-id>
prb device delete <device-id>`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !flagYes {
if !f.IOStreams.IsInteractive() {
return fmt.Errorf("cannot delete device: confirmation required, use --yes to confirm")
}
var confirmed bool
err := huh.NewConfirm().
Title(fmt.Sprintf("Delete device %s?", args[0])).
Value(&confirmed).
Run()
if err != nil {
return err
}
if !confirmed {
return nil
}
}
cfg, err := f.Config()
if err != nil {
return err
}
host, hc, err := cfg.DefaultHost()
if err != nil {
return err
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
cmdutil.TokenRefreshOption(cfg, host, hc),
)
_, err = client.Do(
deleteMutation,
map[string]any{
"input": map[string]any{
"deviceId": args[0],
},
},
)
if err != nil {
return err
}
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Deleted device %s\n",
args[0],
)
return nil
},
}
cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation prompt")
return cmd
}

46
pkg/cmd/device/device.go Normal file
View File

@@ -0,0 +1,46 @@
// 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 device
import (
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cmd/cmdutil"
"go.probo.inc/probo/pkg/cmd/device/delete"
"go.probo.inc/probo/pkg/cmd/device/list"
"go.probo.inc/probo/pkg/cmd/device/revoke"
setowner "go.probo.inc/probo/pkg/cmd/device/set-owner"
"go.probo.inc/probo/pkg/cmd/device/view"
)
func NewCmdDevice(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "device <command>",
Short: "Manage ITAM devices",
}
cmd.AddCommand(list.NewCmdList(f))
cmd.AddCommand(view.NewCmdView(f))
cmd.AddCommand(revoke.NewCmdRevoke(f))
cmd.AddCommand(delete.NewCmdDelete(f))
cmd.AddCommand(setowner.NewCmdSetOwner(f))
return cmd
}

304
pkg/cmd/device/list/list.go Normal file
View File

@@ -0,0 +1,304 @@
// 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 list
import (
"encoding/json"
"fmt"
"github.com/spf13/cobra"
"go.gearno.de/x/ref"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
"go.probo.inc/probo/pkg/cmd/device/shared"
)
const (
listQuery = `
query($id: ID!, $first: Int, $after: CursorKey, $orderBy: DeviceOrder) {
node(id: $id) {
__typename
... on Organization {
devices(first: $first, after: $after, orderBy: $orderBy) {
totalCount
edges {
node {
id
state
hostname
platform
osVersion
agentVersion
serialNumber
lastSeenAt
enrolledAt
owner {
id
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
`
listWithPosturesQuery = `
query($id: ID!, $first: Int, $after: CursorKey, $orderBy: DeviceOrder) {
node(id: $id) {
__typename
... on Organization {
devices(first: $first, after: $after, orderBy: $orderBy) {
totalCount
edges {
node {
id
state
hostname
platform
osVersion
agentVersion
serialNumber
lastSeenAt
enrolledAt
owner {
id
}
latestPostures {
id
checkKey
status
value {
kind
text
number
}
observedAt
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
`
)
type device struct {
ID string `json:"id"`
State string `json:"state"`
Hostname *string `json:"hostname"`
Platform *string `json:"platform"`
OsVersion *string `json:"osVersion"`
AgentVersion *string `json:"agentVersion"`
SerialNumber *string `json:"serialNumber"`
LastSeenAt *string `json:"lastSeenAt"`
EnrolledAt *string `json:"enrolledAt"`
Owner *struct {
ID string `json:"id"`
} `json:"owner"`
LatestPostures []shared.Posture `json:"latestPostures"`
}
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
var (
flagOrg string
flagLimit int
flagOrderBy string
flagOrderDir string
flagPostures bool
flagOutput *string
)
cmd := &cobra.Command{
Use: "list",
Short: "List ITAM devices in an organization",
Aliases: []string{"ls"},
Example: ` # List devices in the default organization
prb device list
# List devices with latest posture check results
prb device ls --postures
# List devices sorted by last seen time
prb device ls --order-by LAST_SEEN_AT --json`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil {
return err
}
if err := cmdutil.ValidateLimit(flagLimit); err != nil {
return err
}
cfg, err := f.Config()
if err != nil {
return err
}
host, hc, err := cfg.DefaultHost()
if err != nil {
return err
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
cmdutil.TokenRefreshOption(cfg, host, hc),
)
if flagOrg == "" {
flagOrg = hc.Organization
}
if flagOrg == "" {
return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'")
}
variables := map[string]any{
"id": flagOrg,
}
if flagOrderBy != "" {
if err := cmdutil.ValidateEnum(
"order-by",
flagOrderBy,
[]string{"CREATED_AT", "UPDATED_AT", "HOSTNAME", "LAST_SEEN_AT"},
); err != nil {
return err
}
if err := cmdutil.ValidateEnum(
"order-direction",
flagOrderDir,
[]string{"ASC", "DESC"},
); err != nil {
return err
}
variables["orderBy"] = map[string]any{
"field": flagOrderBy,
"direction": flagOrderDir,
}
}
query := listQuery
if flagPostures {
query = listWithPosturesQuery
}
devices, totalCount, err := api.Paginate(
client,
query,
variables,
flagLimit,
func(data json.RawMessage) (*api.Connection[device], error) {
var resp struct {
Node *struct {
Typename string `json:"__typename"`
Devices api.Connection[device] `json:"devices"`
} `json:"node"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return nil, err
}
if resp.Node == nil {
return nil, fmt.Errorf("organization %s not found", flagOrg)
}
if resp.Node.Typename != "Organization" {
return nil, fmt.Errorf("expected Organization node, got %s", resp.Node.Typename)
}
return &resp.Node.Devices, nil
},
)
if err != nil {
return err
}
if *flagOutput == cmdutil.OutputJSON {
return cmdutil.PrintJSON(f.IOStreams.Out, devices)
}
if len(devices) == 0 {
_, _ = fmt.Fprintln(f.IOStreams.Out, "No devices found.")
return nil
}
headers := []string{"ID", "STATE", "HOSTNAME", "PLATFORM"}
if flagPostures {
headers = append(headers, "POSTURES")
}
rows := make([][]string, 0, len(devices))
for _, d := range devices {
row := []string{
d.ID,
d.State,
ref.UnrefOrZero(d.Hostname),
ref.UnrefOrZero(d.Platform),
}
if flagPostures {
row = append(row, fmt.Sprintf("%d", len(d.LatestPostures)))
}
rows = append(rows, row)
}
t := cmdutil.NewTable(headers...).Rows(rows...)
_, _ = fmt.Fprintln(f.IOStreams.Out, t)
if totalCount > len(devices) {
_, _ = fmt.Fprintf(
f.IOStreams.ErrOut,
"\nShowing %d of %d devices\n",
len(devices),
totalCount,
)
}
return nil
},
}
cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of devices to list")
cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (CREATED_AT, UPDATED_AT, HOSTNAME, LAST_SEEN_AT)")
cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)")
cmd.Flags().BoolVar(&flagPostures, "postures", false, "Include latest posture check results")
flagOutput = cmdutil.AddOutputFlag(cmd)
return cmd
}

View File

@@ -0,0 +1,130 @@
// 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 revoke
import (
"encoding/json"
"fmt"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const revokeMutation = `
mutation($input: RevokeDeviceInput!) {
revokeDevice(input: $input) {
device {
id
state
}
}
}
`
type revokeResponse struct {
RevokeDevice struct {
Device struct {
ID string `json:"id"`
State string `json:"state"`
} `json:"device"`
} `json:"revokeDevice"`
}
func NewCmdRevoke(f *cmdutil.Factory) *cobra.Command {
var flagYes bool
cmd := &cobra.Command{
Use: "revoke <id>",
Short: "Revoke an ITAM device enrollment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !flagYes {
if !f.IOStreams.IsInteractive() {
return fmt.Errorf("cannot revoke device: confirmation required, use --yes to confirm")
}
var confirmed bool
err := huh.NewConfirm().
Title(fmt.Sprintf("Revoke device %s?", args[0])).
Value(&confirmed).
Run()
if err != nil {
return err
}
if !confirmed {
return nil
}
}
cfg, err := f.Config()
if err != nil {
return err
}
host, hc, err := cfg.DefaultHost()
if err != nil {
return err
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
cmdutil.TokenRefreshOption(cfg, host, hc),
)
data, err := client.Do(
revokeMutation,
map[string]any{
"input": map[string]any{
"deviceId": args[0],
},
},
)
if err != nil {
return err
}
var resp revokeResponse
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("cannot parse response: %w", err)
}
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Revoked device %s (%s)\n",
resp.RevokeDevice.Device.ID,
resp.RevokeDevice.Device.State,
)
return nil
},
}
cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation prompt")
return cmd
}

View File

@@ -0,0 +1,141 @@
// 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 setowner
import (
"encoding/json"
"fmt"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const setOwnerMutation = `
mutation($input: SetDeviceOwnerInput!) {
setDeviceOwner(input: $input) {
device {
id
owner {
id
}
}
}
}
`
type setOwnerResponse struct {
SetDeviceOwner struct {
Device struct {
ID string `json:"id"`
Owner *struct {
ID string `json:"id"`
} `json:"owner"`
} `json:"device"`
} `json:"setDeviceOwner"`
}
func NewCmdSetOwner(f *cmdutil.Factory) *cobra.Command {
var flagOwner string
cmd := &cobra.Command{
Use: "set-owner <id>",
Short: "Set or clear the owner of an ITAM device",
Example: ` # Assign an owner
prb device set-owner <device-id> --owner <profile-id>
# Clear the owner
prb device set-owner <device-id> --owner ""`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !cmd.Flags().Changed("owner") {
return fmt.Errorf("--owner is required (pass an empty value to clear)")
}
cfg, err := f.Config()
if err != nil {
return err
}
host, hc, err := cfg.DefaultHost()
if err != nil {
return err
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
cmdutil.TokenRefreshOption(cfg, host, hc),
)
input := map[string]any{
"deviceId": args[0],
}
if flagOwner == "" {
input["ownerId"] = nil
} else {
input["ownerId"] = flagOwner
}
data, err := client.Do(
setOwnerMutation,
map[string]any{"input": input},
)
if err != nil {
return err
}
var resp setOwnerResponse
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("cannot parse response: %w", err)
}
ownerID := ""
if resp.SetDeviceOwner.Device.Owner != nil {
ownerID = resp.SetDeviceOwner.Device.Owner.ID
}
if ownerID == "" {
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Cleared owner on device %s\n",
resp.SetDeviceOwner.Device.ID,
)
} else {
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Set owner of device %s to %s\n",
resp.SetDeviceOwner.Device.ID,
ownerID,
)
}
return nil
},
}
cmd.Flags().StringVar(&flagOwner, "owner", "", "Owner profile ID (empty to clear)")
return cmd
}

View File

@@ -0,0 +1,37 @@
// 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 shared
type (
PostureValue struct {
Kind string `json:"kind"`
Text string `json:"text"`
Number *int `json:"number"`
}
Posture struct {
ID string `json:"id"`
CheckKey string `json:"checkKey"`
Status string `json:"status"`
Value PostureValue `json:"value"`
ObservedAt string `json:"observedAt"`
}
)

229
pkg/cmd/device/view/view.go Normal file
View File

@@ -0,0 +1,229 @@
// 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 view
import (
"encoding/json"
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
"go.gearno.de/x/ref"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
"go.probo.inc/probo/pkg/cmd/device/shared"
)
const viewQuery = `
query($id: ID!) {
node(id: $id) {
__typename
... on Device {
id
state
hostname
platform
osVersion
agentVersion
serialNumber
hardwareUuid
enrolledAt
lastSeenAt
revokedAt
createdAt
updatedAt
owner {
id
}
latestPostures {
id
checkKey
status
value {
kind
text
number
}
observedAt
}
}
}
}
`
type viewResponse struct {
Node *struct {
Typename string `json:"__typename"`
ID string `json:"id"`
State string `json:"state"`
Hostname *string `json:"hostname"`
Platform *string `json:"platform"`
OsVersion *string `json:"osVersion"`
AgentVersion *string `json:"agentVersion"`
SerialNumber *string `json:"serialNumber"`
HardwareUUID *string `json:"hardwareUuid"`
EnrolledAt *string `json:"enrolledAt"`
LastSeenAt *string `json:"lastSeenAt"`
RevokedAt *string `json:"revokedAt"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Owner *struct {
ID string `json:"id"`
} `json:"owner"`
LatestPostures []shared.Posture `json:"latestPostures"`
} `json:"node"`
}
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
var flagOutput *string
cmd := &cobra.Command{
Use: "view <id>",
Short: "View an ITAM device",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil {
return err
}
cfg, err := f.Config()
if err != nil {
return err
}
host, hc, err := cfg.DefaultHost()
if err != nil {
return err
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
cmdutil.TokenRefreshOption(cfg, host, hc),
)
data, err := client.Do(
viewQuery,
map[string]any{"id": args[0]},
)
if err != nil {
return err
}
var resp viewResponse
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("cannot parse response: %w", err)
}
if resp.Node == nil {
return fmt.Errorf("device %s not found", args[0])
}
if resp.Node.Typename != "Device" {
return fmt.Errorf("expected Device node, got %s", resp.Node.Typename)
}
if *flagOutput == cmdutil.OutputJSON {
return cmdutil.PrintJSON(f.IOStreams.Out, resp.Node)
}
d := resp.Node
out := f.IOStreams.Out
bold := lipgloss.NewStyle().Bold(true)
label := lipgloss.NewStyle().Foreground(lipgloss.Color("242")).Width(22)
title := d.ID
if d.Hostname != nil && *d.Hostname != "" {
title = *d.Hostname
}
_, _ = fmt.Fprintf(out, "%s\n\n", bold.Render(title))
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("ID:"), d.ID)
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("State:"), d.State)
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Hostname:"), ref.UnrefOrZero(d.Hostname))
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Platform:"), ref.UnrefOrZero(d.Platform))
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("OS Version:"), ref.UnrefOrZero(d.OsVersion))
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Agent Version:"), ref.UnrefOrZero(d.AgentVersion))
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Serial Number:"), ref.UnrefOrZero(d.SerialNumber))
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Hardware UUID:"), ref.UnrefOrZero(d.HardwareUUID))
if d.Owner != nil {
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Owner:"), d.Owner.ID)
} else {
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Owner:"), "")
}
if d.EnrolledAt != nil {
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Enrolled:"), cmdutil.FormatTime(*d.EnrolledAt))
}
if d.LastSeenAt != nil {
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Last Seen:"), cmdutil.FormatTime(*d.LastSeenAt))
}
if d.RevokedAt != nil {
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Revoked:"), cmdutil.FormatTime(*d.RevokedAt))
}
_, _ = fmt.Fprintln(out)
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(d.CreatedAt))
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Updated:"), cmdutil.FormatTime(d.UpdatedAt))
_, _ = fmt.Fprintln(out)
_, _ = fmt.Fprintf(out, "%s\n", bold.Render("Latest Postures"))
if len(d.LatestPostures) == 0 {
_, _ = fmt.Fprintln(out, "No postures recorded.")
return nil
}
rows := make([][]string, 0, len(d.LatestPostures))
for _, p := range d.LatestPostures {
value := p.Value.Kind
if p.Value.Text != "" {
value = p.Value.Text
} else if p.Value.Number != nil {
value = fmt.Sprintf("%s (%d)", p.Value.Kind, *p.Value.Number)
}
rows = append(rows, []string{
p.CheckKey,
p.Status,
value,
cmdutil.FormatTime(p.ObservedAt),
})
}
t := cmdutil.NewTable("CHECK", "STATUS", "VALUE", "OBSERVED").Rows(rows...)
_, _ = fmt.Fprintln(out, t)
return nil
},
}
flagOutput = cmdutil.AddOutputFlag(cmd)
return cmd
}

View File

@@ -39,6 +39,7 @@ import (
cookiebanner "go.probo.inc/probo/pkg/cmd/cookie-banner"
cookiecategory "go.probo.inc/probo/pkg/cmd/cookie-category"
"go.probo.inc/probo/pkg/cmd/datum"
"go.probo.inc/probo/pkg/cmd/device"
"go.probo.inc/probo/pkg/cmd/document"
"go.probo.inc/probo/pkg/cmd/dpia"
"go.probo.inc/probo/pkg/cmd/evidence"
@@ -113,6 +114,7 @@ func NewCmdRoot(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(trackerpattern.NewCmdTrackerPattern(f))
cmd.AddCommand(trackerresource.NewCmdTrackerResource(f))
cmd.AddCommand(datum.NewCmdDatum(f))
cmd.AddCommand(device.NewCmdDevice(f))
cmd.AddCommand(document.NewCmdDocument(f))
cmd.AddCommand(dpia.NewCmdDPIA(f))
cmd.AddCommand(evidence.NewCmdEvidence(f))

View File

@@ -62,6 +62,10 @@ var (
// because it is not REVOKED.
ErrDeviceNotDeletable = errors.New("device cannot be deleted")
// ErrInvalidOwnerProfile is returned when an owner ID is not a
// membership profile of the device organization.
ErrInvalidOwnerProfile = errors.New("invalid owner profile")
// ErrCorrelationIDRequired is returned when a posture result is
// missing a correlation ID.
ErrCorrelationIDRequired = errors.New("correlation_id is required")
@@ -642,7 +646,7 @@ func (s *Service) validateOwnerProfileID(
}
if ownerID.EntityType() != coredata.MembershipProfileEntityType {
return nil, fmt.Errorf("owner_id must be a membership profile")
return nil, fmt.Errorf("%w: owner_id must be a membership profile", ErrInvalidOwnerProfile)
}
profile := &coredata.MembershipProfile{}
@@ -651,7 +655,7 @@ func (s *Service) validateOwnerProfileID(
}
if profile.OrganizationID != organizationID {
return nil, fmt.Errorf("owner profile does not belong to organization")
return nil, fmt.Errorf("%w: owner profile does not belong to organization", ErrInvalidOwnerProfile)
}
return ownerID, nil

View File

@@ -245,6 +245,7 @@ func NewServer(cfg Config) (*Server, error) {
cfg.AccessReview,
cfg.CookieBanner,
cfg.RiskManagement,
cfg.ITAM,
cfg.TokenSecret,
cfg.File,
cfg.BaseURL,

View File

@@ -199,6 +199,10 @@ func (r *mutationResolver) CreateDevice(ctx context.Context, input types.CreateD
return nil, gqlutils.NotFound(ctx, err)
}
if errors.Is(err, itam.ErrInvalidOwnerProfile) {
return nil, gqlutils.Invalid(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot create device", log.Error(err))
return nil, gqlutils.Internal(ctx)
@@ -277,6 +281,10 @@ func (r *mutationResolver) SetDeviceOwner(ctx context.Context, input types.SetDe
return nil, gqlutils.NotFound(ctx, err)
}
if errors.Is(err, itam.ErrInvalidOwnerProfile) {
return nil, gqlutils.Invalid(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot set device owner", log.Error(err))
return nil, gqlutils.Internal(ctx)

View File

@@ -38,6 +38,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/probo"
"go.probo.inc/probo/pkg/prosemirror"
"go.probo.inc/probo/pkg/resourcealias"
@@ -47,6 +48,10 @@ import (
"go.probo.inc/probo/pkg/thirdparty"
)
// maxDeviceListSize caps the listDevices page size: with include_postures the
// resolver runs one posture query per returned device.
const maxDeviceListSize = 100
type Resolver struct {
proboSvc *probo.Service
management *management.Service
@@ -57,6 +62,7 @@ type Resolver struct {
accessReview *accessreview.Service
cookieBanner *cookiebanner.Service
riskManagement *riskmanagement.Service
itamSvc *itam.Service
logger *log.Logger
fileManager *filemanager.Service
baseURL *baseurl.BaseURL

View File

@@ -19,6 +19,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/mail"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo"
@@ -7422,3 +7423,182 @@ func (r *Resolver) RequestSCIMEventExportTool(ctx context.Context, req *mcp.Call
ExportJobID: logExport.ID,
}, nil
}
func (r *Resolver) ListDevicesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListDevicesInput) (*mcp.CallToolResult, types.ListDevicesOutput, error) {
scope, err := r.Authorize(ctx, input.OrganizationID, itam.ActionDeviceList)
if err != nil {
return nil, types.ListDevicesOutput{}, err
}
pageOrderBy := page.OrderBy[coredata.DeviceOrderField]{
Field: coredata.DeviceOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if input.OrderBy != nil {
pageOrderBy = page.OrderBy[coredata.DeviceOrderField]{
Field: input.OrderBy.Field,
Direction: input.OrderBy.Direction,
}
}
size := input.Size
if size != nil && *size > maxDeviceListSize {
size = new(maxDeviceListSize)
}
cursor := types.NewCursor(size, input.Cursor, pageOrderBy)
devicePage, err := r.itamSvc.ListForOrganizationID(ctx, scope, input.OrganizationID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list devices", log.Error(err))
return nil, types.ListDevicesOutput{}, fmt.Errorf("internal server error")
}
includePostures := input.IncludePostures != nil && *input.IncludePostures
var postureScope *coredata.Scope
if includePostures && len(devicePage.Data) > 0 {
deviceIDs := make([]gid.GID, 0, len(devicePage.Data))
for _, d := range devicePage.Data {
deviceIDs = append(deviceIDs, d.ID)
}
postureScope, err = r.AuthorizeBatch(ctx, deviceIDs, itam.ActionDevicePostureList)
if err != nil {
return nil, types.ListDevicesOutput{}, err
}
}
posturesByDeviceID := make(map[gid.GID]coredata.DevicePostures, len(devicePage.Data))
for _, d := range devicePage.Data {
if postureScope == nil {
continue
}
postures, err := r.itamSvc.GetLatestPostures(ctx, postureScope, d.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot load latest device postures", log.Error(err))
return nil, types.ListDevicesOutput{}, fmt.Errorf("internal server error")
}
posturesByDeviceID[d.ID] = postures
}
return nil, types.NewListDevicesOutput(devicePage, posturesByDeviceID), nil
}
func (r *Resolver) GetDeviceTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetDeviceInput) (*mcp.CallToolResult, types.GetDeviceOutput, error) {
scope, err := r.Authorize(ctx, input.ID, itam.ActionDeviceGet)
if err != nil {
return nil, types.GetDeviceOutput{}, err
}
device, err := r.itamSvc.GetDevice(ctx, scope, input.ID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, types.GetDeviceOutput{}, fmt.Errorf("resource not found")
}
r.logger.ErrorCtx(ctx, "cannot get device", log.Error(err))
return nil, types.GetDeviceOutput{}, fmt.Errorf("internal server error")
}
var postures coredata.DevicePostures
if input.IncludePostures != nil && *input.IncludePostures {
postureScope, err := r.Authorize(ctx, input.ID, itam.ActionDevicePostureList)
if err != nil {
return nil, types.GetDeviceOutput{}, err
}
postures, err = r.itamSvc.GetLatestPostures(ctx, postureScope, device.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot load latest device postures", log.Error(err))
return nil, types.GetDeviceOutput{}, fmt.Errorf("internal server error")
}
}
return nil, types.GetDeviceOutput{
Device: types.NewDevice(device, postures),
}, nil
}
func (r *Resolver) RevokeDeviceTool(ctx context.Context, req *mcp.CallToolRequest, input *types.RevokeDeviceInput) (*mcp.CallToolResult, types.RevokeDeviceOutput, error) {
scope, err := r.Authorize(ctx, input.ID, itam.ActionDeviceRevoke)
if err != nil {
return nil, types.RevokeDeviceOutput{}, err
}
device, err := r.itamSvc.RevokeDevice(ctx, scope, input.ID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, types.RevokeDeviceOutput{}, fmt.Errorf("resource not found")
}
r.logger.ErrorCtx(ctx, "cannot revoke device", log.Error(err))
return nil, types.RevokeDeviceOutput{}, fmt.Errorf("internal server error")
}
return nil, types.RevokeDeviceOutput{
Device: types.NewDevice(device, nil),
}, nil
}
func (r *Resolver) DeleteDeviceTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteDeviceInput) (*mcp.CallToolResult, types.DeleteDeviceOutput, error) {
scope, err := r.Authorize(ctx, input.ID, itam.ActionDeviceDelete)
if err != nil {
return nil, types.DeleteDeviceOutput{}, err
}
device, err := r.itamSvc.DeleteDevice(ctx, scope, input.ID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, types.DeleteDeviceOutput{}, fmt.Errorf("resource not found")
}
if errors.Is(err, itam.ErrDeviceNotDeletable) {
return nil, types.DeleteDeviceOutput{}, fmt.Errorf("device cannot be deleted")
}
r.logger.ErrorCtx(ctx, "cannot delete device", log.Error(err))
return nil, types.DeleteDeviceOutput{}, fmt.Errorf("internal server error")
}
return nil, types.DeleteDeviceOutput{
DeletedDeviceID: device.ID,
}, nil
}
func (r *Resolver) SetDeviceOwnerTool(ctx context.Context, req *mcp.CallToolRequest, input *types.SetDeviceOwnerInput) (*mcp.CallToolResult, types.SetDeviceOwnerOutput, error) {
scope, err := r.Authorize(ctx, input.ID, itam.ActionDeviceAssignOwner)
if err != nil {
return nil, types.SetDeviceOwnerOutput{}, err
}
device, err := r.itamSvc.SetDeviceOwner(ctx, scope, input.ID, input.OwnerID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, types.SetDeviceOwnerOutput{}, fmt.Errorf("resource not found")
}
if errors.Is(err, itam.ErrInvalidOwnerProfile) {
return nil, types.SetDeviceOwnerOutput{}, fmt.Errorf("owner_id must reference a membership profile of the device organization")
}
r.logger.ErrorCtx(ctx, "cannot set device owner", log.Error(err))
return nil, types.SetDeviceOwnerOutput{}, fmt.Errorf("internal server error")
}
return nil, types.SetDeviceOwnerOutput{
Device: types.NewDevice(device, nil),
}, nil
}

View File

@@ -2872,6 +2872,319 @@ components:
$ref: "#/components/schemas/GID"
description: Deleted asset ID
DeviceState:
type: string
description: Device lifecycle state. PENDING means an enrollment token was issued but the agent has never checked in; ACTIVE means the agent is heartbeating; REVOKED means enrollment was revoked.
enum:
- PENDING
- ACTIVE
- REVOKED
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DeviceState
DevicePlatform:
type: string
enum:
- DARWIN
- LINUX
- FREEBSD
- WINDOWS
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DevicePlatform
DevicePostureStatus:
type: string
description: Posture check verdict. PASS and FAIL are compliance outcomes; UNKNOWN means the agent could not determine a result; NOT_APPLICABLE means the check does not apply on this host or platform (for example no screen-lock tool on a headless Linux host).
enum:
- PASS
- FAIL
- UNKNOWN
- NOT_APPLICABLE
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DevicePostureStatus
DevicePostureValueKind:
type: string
description: Machine-readable observation class for a posture value. Use kind to interpret text and number; kind is not the compliance verdict (see status). SECONDS and MIN_PASSWORD_LENGTH carry a value in number; TEXT carries a literal in text; other kinds are self-describing (ON, OFF, IMMEDIATE, CONFIGURED, NONE, UNKNOWN).
enum:
- ON
- OFF
- IMMEDIATE
- SECONDS
- MIN_PASSWORD_LENGTH
- CONFIGURED
- NONE
- TEXT
- UNKNOWN
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DevicePostureValueKind
DeviceOrderField:
type: string
enum:
- CREATED_AT
- UPDATED_AT
- HOSTNAME
- LAST_SEEN_AT
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DeviceOrderField
DeviceOrderBy:
type: object
required:
- field
- direction
properties:
field:
$ref: "#/components/schemas/DeviceOrderField"
description: Device order field
direction:
$ref: "#/components/schemas/OrderDirection"
description: Device order direction
DevicePostureValue:
type: object
required:
- kind
- text
properties:
kind:
$ref: "#/components/schemas/DevicePostureValueKind"
description: Machine-readable observation class (ON, OFF, TEXT, SECONDS, and so on). Interpret text and number based on kind; this is not the compliance verdict.
text:
type: string
description: Literal posture value when kind is TEXT (for example an OS version or engine name). Often empty for non-TEXT kinds.
number:
type:
- integer
- "null"
description: Numeric posture value when kind is SECONDS (delay) or MIN_PASSWORD_LENGTH (character count); null otherwise.
DevicePosture:
type: object
required:
- id
- device_id
- check_key
- status
- value
- observed_at
properties:
id:
$ref: "#/components/schemas/GID"
description: Device posture ID
device_id:
$ref: "#/components/schemas/GID"
description: Device ID
check_key:
type: string
description: Posture check identifier (for example DISK_ENCRYPTION, SCREEN_LOCK, FIREWALL_ENABLED, TIME_SYNC, OS_VERSION, AUTO_UPDATE, PASSWORD_POLICY, REMOTE_LOGIN, MALWARE_PROTECTION).
status:
$ref: "#/components/schemas/DevicePostureStatus"
description: Posture check verdict (PASS, FAIL, UNKNOWN, or NOT_APPLICABLE). NOT_APPLICABLE means the check does not apply on this host or platform.
value:
$ref: "#/components/schemas/DevicePostureValue"
description: Observed posture value for this check_key; use value.kind to interpret value.text and value.number.
observed_at:
type: string
format: date-time
description: Observation timestamp
Device:
type: object
required:
- id
- organization_id
- state
- latest_postures
- created_at
- updated_at
properties:
id:
$ref: "#/components/schemas/GID"
description: Device ID
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
state:
$ref: "#/components/schemas/DeviceState"
description: Device lifecycle state (PENDING, ACTIVE, or REVOKED).
hostname:
type:
- string
- "null"
description: Device hostname
serial_number:
type:
- string
- "null"
description: Device serial number
hardware_uuid:
type:
- string
- "null"
description: Device hardware UUID
platform:
anyOf:
- $ref: "#/components/schemas/DevicePlatform"
- type: "null"
description: Device platform
os_version:
type:
- string
- "null"
description: Operating system version
agent_version:
type:
- string
- "null"
description: Agent version
owner_id:
anyOf:
- type: string
$ref: "#/components/schemas/GID"
- type: "null"
description: MembershipProfile GID of the device owner, or null when unassigned.
enrolled_at:
type:
- string
- "null"
format: date-time
description: Enrollment timestamp
last_seen_at:
type:
- string
- "null"
format: date-time
description: Last agent heartbeat timestamp; use as the staleness signal. Null while the device is still PENDING.
revoked_at:
type:
- string
- "null"
format: date-time
description: Time of the first revoke. Set once and kept on subsequent revokeDevice calls.
latest_postures:
type: array
items:
$ref: "#/components/schemas/DevicePosture"
description: Newest posture result per check_key when include_postures was true on listDevices or getDevice; otherwise empty. Also empty for PENDING devices that have never reported.
created_at:
type: string
format: date-time
description: Creation timestamp
updated_at:
type: string
format: date-time
description: Update timestamp
ListDevicesInput:
type: object
required:
- organization_id
properties:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
order_by:
$ref: "#/components/schemas/DeviceOrderBy"
description: Device order by
size:
type: integer
description: Number of devices to return in this page.
cursor:
$ref: "#/components/schemas/CursorKey"
description: Opaque cursor from a previous next_cursor; omit on the first page.
include_postures:
type: boolean
description: When true, include each device's latest posture check results. Requires the itam:device-posture:list permission and runs one extra query per device.
ListDevicesOutput:
type: object
required:
- devices
properties:
next_cursor:
$ref: "#/components/schemas/CursorKey"
description: Cursor for the next page; pass as cursor on the next listDevices call. Absent when there are no more results.
devices:
type: array
items:
$ref: "#/components/schemas/Device"
GetDeviceInput:
type: object
required:
- id
properties:
id:
$ref: "#/components/schemas/GID"
description: Device ID
include_postures:
type: boolean
description: When true, include the device's latest posture check results. Requires the itam:device-posture:list permission.
GetDeviceOutput:
type: object
required:
- device
properties:
device:
$ref: "#/components/schemas/Device"
RevokeDeviceInput:
type: object
required:
- id
properties:
id:
$ref: "#/components/schemas/GID"
description: Device ID
RevokeDeviceOutput:
type: object
required:
- device
properties:
device:
$ref: "#/components/schemas/Device"
DeleteDeviceInput:
type: object
required:
- id
properties:
id:
$ref: "#/components/schemas/GID"
description: Device ID
DeleteDeviceOutput:
type: object
required:
- deleted_device_id
properties:
deleted_device_id:
$ref: "#/components/schemas/GID"
description: Deleted device ID
SetDeviceOwnerInput:
type: object
required:
- id
- owner_id
properties:
id:
$ref: "#/components/schemas/GID"
description: Device ID
owner_id:
anyOf:
- type: string
$ref: "#/components/schemas/GID"
- type: "null"
description: MembershipProfile GID belonging to the same organization as the device, or null to clear the owner. Required; omitting the field is invalid.
SetDeviceOwnerOutput:
type: object
required:
- device
properties:
device:
$ref: "#/components/schemas/Device"
DataClassification:
type: string
enum:
@@ -13204,6 +13517,66 @@ tools:
$ref: "#/components/schemas/DeleteAssetInput"
outputSchema:
$ref: "#/components/schemas/DeleteAssetOutput"
- name: listDevices
title: List Devices
description: "List ITAM devices for an organization. Devices cannot be created through the MCP API: enrollment issues a one-shot token that the agent installer exchanges (there is no createDevice tool). Device states: PENDING (enrollment token issued, agent has never checked in), ACTIVE (agent heartbeating), REVOKED (enrollment revoked). Use last_seen_at as the staleness signal. latest_postures is empty unless include_postures is true; when loaded it holds the newest result per check_key and is empty for PENDING devices. Page with size and cursor; when next_cursor is present, pass it as cursor on the next call."
hints:
readonly: true
destructive: false
idempotent: true
openWorld: false
inputSchema:
$ref: "#/components/schemas/ListDevicesInput"
outputSchema:
$ref: "#/components/schemas/ListDevicesOutput"
- name: getDevice
title: Get Device
description: "Get one ITAM device by ID (soft-deleted devices are not returned). Same state machine as listDevices: PENDING (enrollment token issued, agent has never checked in), ACTIVE (agent heartbeating), REVOKED (enrollment revoked). Use last_seen_at as the staleness signal. latest_postures is empty unless include_postures is true; when loaded it holds the newest result per check_key and is empty for PENDING devices."
hints:
readonly: true
destructive: false
idempotent: true
openWorld: false
inputSchema:
$ref: "#/components/schemas/GetDeviceInput"
outputSchema:
$ref: "#/components/schemas/GetDeviceOutput"
- name: revokeDevice
title: Revoke Device
description: "Irreversibly revoke a device enrollment. Immediately invalidates the device agent API key so the agent stops authenticating and reporting; there is no un-revoke tool. Safe to call more than once: state stays REVOKED and revoked_at keeps its original value. Call this before deleteDevice."
hints:
readonly: false
destructive: true
idempotent: true
openWorld: false
inputSchema:
$ref: "#/components/schemas/RevokeDeviceInput"
outputSchema:
$ref: "#/components/schemas/RevokeDeviceOutput"
- name: deleteDevice
title: Delete Device
description: "Soft-delete a device. The device must already be REVOKED — call revokeDevice first, otherwise the call fails with the error device cannot be deleted. After success the device stops appearing in listDevices/getDevice; enrollment tokens for the device are removed. Eligible orphan rows are later hard-deleted by the ITAM garbage collector."
hints:
readonly: false
destructive: true
idempotent: true
openWorld: false
inputSchema:
$ref: "#/components/schemas/DeleteDeviceInput"
outputSchema:
$ref: "#/components/schemas/DeleteDeviceOutput"
- name: setDeviceOwner
title: Set Device Owner
description: "Set or clear the owner of an ITAM device. owner_id is required: pass a MembershipProfile GID belonging to the same organization as the device to assign, or null to clear. Omitting the field is invalid."
hints:
readonly: false
destructive: false
idempotent: true
openWorld: false
inputSchema:
$ref: "#/components/schemas/SetDeviceOwnerInput"
outputSchema:
$ref: "#/components/schemas/SetDeviceOwnerOutput"
- name: listData
title: List Data
description: List all data for the organization

View File

@@ -0,0 +1,100 @@
// 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 (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
func NewDevicePostureValue(v coredata.DevicePostureValue) *DevicePostureValue {
return &DevicePostureValue{
Kind: v.Kind,
Text: v.Text,
Number: v.Number,
}
}
func NewDevicePosture(p *coredata.DevicePosture) *DevicePosture {
value := coredata.ParseDevicePostureValue(p.CheckKey, p.Evidence)
return &DevicePosture{
ID: p.ID,
DeviceID: p.DeviceID,
CheckKey: p.CheckKey,
Status: p.Status,
Value: NewDevicePostureValue(value),
ObservedAt: p.ObservedAt,
}
}
func NewDevicePostures(ps coredata.DevicePostures) []*DevicePosture {
out := make([]*DevicePosture, 0, len(ps))
for _, p := range ps {
out = append(out, NewDevicePosture(p))
}
return out
}
func NewDevice(d *coredata.Device, postures coredata.DevicePostures) *Device {
return &Device{
ID: d.ID,
OrganizationID: d.OrganizationID,
State: d.State,
Hostname: d.Hostname,
SerialNumber: d.SerialNumber,
HardwareUUID: d.HardwareUUID,
Platform: d.Platform,
OsVersion: d.OSVersion,
AgentVersion: d.AgentVersion,
OwnerID: d.OwnerID,
EnrolledAt: d.EnrolledAt,
LastSeenAt: d.LastSeenAt,
RevokedAt: d.RevokedAt,
LatestPostures: NewDevicePostures(postures),
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
}
}
func NewListDevicesOutput(
devicePage *page.Page[*coredata.Device, coredata.DeviceOrderField],
posturesByDeviceID map[gid.GID]coredata.DevicePostures,
) ListDevicesOutput {
devices := make([]*Device, 0, len(devicePage.Data))
for _, d := range devicePage.Data {
devices = append(devices, NewDevice(d, posturesByDeviceID[d.ID]))
}
var nextCursor *page.CursorKey
if len(devicePage.Data) > 0 {
cursorKey := devicePage.Data[len(devicePage.Data)-1].CursorKey(devicePage.Cursor.OrderBy.Field)
nextCursor = &cursorKey
}
return ListDevicesOutput{
NextCursor: nextCursor,
Devices: devices,
}
}

View File

@@ -34,6 +34,7 @@ import (
"go.probo.inc/probo/pkg/cookiebanner"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/itam"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/resourcealias"
"go.probo.inc/probo/pkg/riskmanagement"
@@ -54,6 +55,7 @@ func NewMux(
accessReviewSvc *accessreview.Service,
cookieBannerSvc *cookiebanner.Service,
riskManagementSvc *riskmanagement.Service,
itamSvc *itam.Service,
tokenSecret string,
fileManagerSvc *filemanager.Service,
baseURL *baseurl.BaseURL,
@@ -72,6 +74,7 @@ func NewMux(
accessReview: accessReviewSvc,
cookieBanner: cookieBannerSvc,
riskManagement: riskManagementSvc,
itamSvc: itamSvc,
logger: logger,
fileManager: fileManagerSvc,
baseURL: baseURL,