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:
225
e2e/mcp/device_test.go
Normal file
225
e2e/mcp/device_test.go
Normal 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")
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
131
packages/n8n-node/nodes/Probo/actions/device/get.operation.ts
Normal file
131
packages/n8n-node/nodes/Probo/actions/device/get.operation.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
183
packages/n8n-node/nodes/Probo/actions/device/getAll.operation.ts
Normal file
183
packages/n8n-node/nodes/Probo/actions/device/getAll.operation.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
86
packages/n8n-node/nodes/Probo/actions/device/index.ts
Normal file
86
packages/n8n-node/nodes/Probo/actions/device/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
114
pkg/cmd/device/delete/delete.go
Normal file
114
pkg/cmd/device/delete/delete.go
Normal 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
46
pkg/cmd/device/device.go
Normal 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
304
pkg/cmd/device/list/list.go
Normal 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
|
||||
}
|
||||
130
pkg/cmd/device/revoke/revoke.go
Normal file
130
pkg/cmd/device/revoke/revoke.go
Normal 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
|
||||
}
|
||||
141
pkg/cmd/device/set-owner/set_owner.go
Normal file
141
pkg/cmd/device/set-owner/set_owner.go
Normal 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
|
||||
}
|
||||
37
pkg/cmd/device/shared/posture.go
Normal file
37
pkg/cmd/device/shared/posture.go
Normal 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
229
pkg/cmd/device/view/view.go
Normal 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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -245,6 +245,7 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
cfg.AccessReview,
|
||||
cfg.CookieBanner,
|
||||
cfg.RiskManagement,
|
||||
cfg.ITAM,
|
||||
cfg.TokenSecret,
|
||||
cfg.File,
|
||||
cfg.BaseURL,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
100
pkg/server/api/mcp/v1/types/device.go
Normal file
100
pkg/server/api/mcp/v1/types/device.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user