Show posture values and report history

Pass/fail was the main device UI signal, but operators need
the agent's observed value. Expose a formatted value per
check, show current postures on the device page, and replace
the Postures tab with paginated report history grouped by
agent push time. Status stays in the model for later rulesets.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-07-28 16:21:30 +02:00
parent 6e2a2ff995
commit ad615a47a0
39 changed files with 4439 additions and 399 deletions

View File

@@ -39,6 +39,7 @@ type (
TenantID gid.TenantID `db:"tenant_id"`
OrganizationID gid.GID `db:"organization_id"`
DeviceID gid.GID `db:"device_id"`
CorrelationID gid.GID `db:"correlation_id"`
CheckKey string `db:"check_key"`
Status DevicePostureStatus `db:"status"`
Evidence json.RawMessage `db:"evidence"`
@@ -86,6 +87,7 @@ INSERT INTO device_postures (
tenant_id,
organization_id,
device_id,
correlation_id,
check_key,
status,
evidence,
@@ -96,6 +98,7 @@ INSERT INTO device_postures (
@tenant_id,
@organization_id,
@device_id,
@correlation_id,
@check_key,
@status,
@evidence,
@@ -108,11 +111,12 @@ INSERT INTO device_postures (
"tenant_id": scope.GetTenantID(),
"organization_id": p.OrganizationID,
"device_id": p.DeviceID,
"correlation_id": p.CorrelationID,
"check_key": p.CheckKey,
"status": p.Status,
"evidence": evidence,
"observed_at": observedAt,
"created_at": p.CreatedAt,
"created_at": now,
}
if _, err := conn.Exec(ctx, q, args); err != nil {
@@ -138,8 +142,9 @@ func normalizeObservedAt(observed, now time.Time) time.Time {
return observed
}
// LoadLatestByDeviceID loads a page of the latest posture row for each
// check_key on the given device.
// LoadLatestByDeviceID loads the latest posture per check_key by observed_at.
// A check that errored in the last run keeps its last known result, so the set
// can span multiple reports.
func (p *DevicePostures) LoadLatestByDeviceID(
ctx context.Context,
conn pg.Querier,
@@ -154,6 +159,7 @@ WITH latest AS (
tenant_id,
organization_id,
device_id,
correlation_id,
check_key,
status,
evidence,
@@ -189,6 +195,64 @@ SELECT * FROM latest WHERE %s
return nil
}
func (p *DevicePostures) LoadByDeviceIDAndCorrelationIDs(
ctx context.Context,
conn pg.Querier,
scope Scoper,
deviceID gid.GID,
correlationIDs []gid.GID,
) error {
if len(correlationIDs) == 0 {
*p = nil
return nil
}
q := `
SELECT
id,
tenant_id,
organization_id,
device_id,
correlation_id,
check_key,
status,
evidence,
observed_at,
created_at
FROM
device_postures
WHERE
%s
AND device_id = @device_id
AND correlation_id = ANY(@correlation_ids)
ORDER BY
created_at DESC,
check_key ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"device_id": deviceID,
"correlation_ids": correlationIDs,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query device postures by correlation_id: %w", err)
}
postures, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DevicePosture])
if err != nil {
return fmt.Errorf("cannot collect device postures by correlation_id: %w", err)
}
*p = postures
return nil
}
// LoadHistoryByDeviceIDAndCheckKey returns the most recent N entries for one
// (device, check_key) pair, newest first.
func (p *DevicePostures) LoadHistoryByDeviceIDAndCheckKey(
@@ -209,6 +273,7 @@ SELECT
tenant_id,
organization_id,
device_id,
correlation_id,
check_key,
status,
evidence,

View File

@@ -0,0 +1,145 @@
// 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 coredata
import (
"context"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
DevicePostureReport struct {
ID gid.GID `db:"id"`
DeviceID gid.GID `db:"device_id"`
CreatedAt time.Time `db:"created_at"`
Postures DevicePostures `db:"-"`
}
DevicePostureReports []*DevicePostureReport
)
func (s DevicePostureReport) CursorKey(
orderBy DevicePostureReportOrderField,
) page.CursorKey {
switch orderBy {
case DevicePostureReportOrderFieldCreatedAt:
return page.NewCursorKey(s.ID, s.CreatedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (s *DevicePostureReports) LoadByDeviceID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
deviceID gid.GID,
cursor *page.Cursor[DevicePostureReportOrderField],
) error {
q := `
WITH reports AS (
SELECT
correlation_id AS id,
device_id,
MIN(created_at) AS created_at
FROM
device_postures
WHERE
%s
AND device_id = @device_id
GROUP BY
device_id,
correlation_id
)
SELECT
id,
device_id,
created_at
FROM
reports
WHERE %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"device_id": deviceID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query device posture reports: %w", err)
}
reports, err := pgx.CollectRows(
rows,
pgx.RowToAddrOfStructByName[DevicePostureReport],
)
if err != nil {
return fmt.Errorf("cannot collect device posture reports: %w", err)
}
*s = reports
return nil
}
func (s *DevicePostureReports) CountByDeviceID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
deviceID gid.GID,
) (int, error) {
q := `
SELECT
COUNT(*)
FROM (
SELECT
correlation_id
FROM
device_postures
WHERE
%s
AND device_id = @device_id
GROUP BY
correlation_id
) AS reports
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"device_id": deviceID}
maps.Copy(args, scope.SQLArguments())
row := conn.QueryRow(ctx, q, args)
var count int
if err := row.Scan(&count); err != nil {
return 0, fmt.Errorf("cannot count device posture reports: %w", err)
}
return count, nil
}

View File

@@ -0,0 +1,84 @@
// 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 coredata
import (
"encoding"
"fmt"
"go.probo.inc/probo/pkg/page"
)
type DevicePostureReportOrderField string
const (
DevicePostureReportOrderFieldCreatedAt DevicePostureReportOrderField = "CREATED_AT"
)
var (
_ page.OrderField = DevicePostureReportOrderField("")
_ fmt.Stringer = DevicePostureReportOrderField("")
_ encoding.TextMarshaler = DevicePostureReportOrderField("")
_ encoding.TextUnmarshaler = (*DevicePostureReportOrderField)(nil)
)
func DevicePostureReportOrderFields() []DevicePostureReportOrderField {
return []DevicePostureReportOrderField{
DevicePostureReportOrderFieldCreatedAt,
}
}
func (v DevicePostureReportOrderField) IsValid() bool {
switch v {
case DevicePostureReportOrderFieldCreatedAt:
return true
}
return false
}
func (v DevicePostureReportOrderField) String() string {
return string(v)
}
func (v DevicePostureReportOrderField) MarshalText() ([]byte, error) {
return []byte(v.String()), nil
}
func (v *DevicePostureReportOrderField) UnmarshalText(text []byte) error {
val := DevicePostureReportOrderField(text)
if !val.IsValid() {
return fmt.Errorf("invalid DevicePostureReportOrderField value: %q", string(text))
}
*v = val
return nil
}
func (f DevicePostureReportOrderField) Column() string {
switch f {
case DevicePostureReportOrderFieldCreatedAt:
return "created_at"
}
panic(fmt.Sprintf("unsupported order by: %s", f))
}

View File

@@ -0,0 +1,322 @@
// 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 coredata_test
import (
"context"
"encoding/json"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/internal/test"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
func insertDevicePostureWithEvidence(
t *testing.T,
ctx context.Context,
client *pg.Client,
fx devicePostureFixture,
checkKey string,
status coredata.DevicePostureStatus,
evidence map[string]any,
correlationID gid.GID,
createdAt time.Time,
) {
t.Helper()
raw, err := json.Marshal(evidence)
require.NoError(t, err)
posture := coredata.DevicePosture{
ID: gid.New(fx.scope.GetTenantID(), coredata.DevicePostureEntityType),
OrganizationID: fx.organizationID,
DeviceID: fx.deviceID,
CorrelationID: correlationID,
CheckKey: checkKey,
Status: status,
Evidence: raw,
ObservedAt: createdAt,
CreatedAt: createdAt,
}
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return posture.Insert(ctx, tx, fx.scope)
}))
}
func TestDevicePostureReport_LoadByDeviceID_GroupsByCorrelationID(t *testing.T) {
t.Parallel()
client := test.PGClient(t)
ctx := context.Background()
fx := seedDevicePostureFixture(t, ctx, client)
now := time.Now().UTC().Truncate(time.Microsecond)
older := now.Add(-time.Hour)
newer := now
olderCorr := gid.New(fx.scope.GetTenantID(), coredata.DevicePostureReportEntityType)
newerCorr := gid.New(fx.scope.GetTenantID(), coredata.DevicePostureReportEntityType)
insertDevicePostureWithEvidence(
t, ctx, client, fx,
"OS_VERSION",
coredata.DevicePostureStatusPass,
map[string]any{"product_version": "14.0"},
olderCorr,
older,
)
insertDevicePostureWithEvidence(
t, ctx, client, fx,
"DISK_ENCRYPTION",
coredata.DevicePostureStatusPass,
map[string]any{"raw": "FileVault is On."},
olderCorr,
older,
)
insertDevicePostureWithEvidence(
t, ctx, client, fx,
"OS_VERSION",
coredata.DevicePostureStatusPass,
map[string]any{"product_version": "15.4"},
newerCorr,
newer,
)
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
orderBy := page.OrderBy[coredata.DevicePostureReportOrderField]{
Field: coredata.DevicePostureReportOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := page.NewCursor(10, nil, page.Head, orderBy)
var reports coredata.DevicePostureReports
require.NoError(t, reports.LoadByDeviceID(ctx, conn, fx.scope, fx.deviceID, cursor))
p := page.NewPage(reports, cursor)
require.Len(t, p.Data, 2)
assert.Equal(t, newerCorr, p.Data[0].ID)
assert.Equal(t, olderCorr, p.Data[1].ID)
assert.True(t, p.Data[0].CreatedAt.Equal(newer))
assert.True(t, p.Data[1].CreatedAt.Equal(older))
correlationIDs := []gid.GID{p.Data[0].ID, p.Data[1].ID}
var postures coredata.DevicePostures
require.NoError(t, postures.LoadByDeviceIDAndCorrelationIDs(
ctx, conn, fx.scope, fx.deviceID, correlationIDs,
))
require.Len(t, postures, 3)
var counter coredata.DevicePostureReports
count, err := counter.CountByDeviceID(ctx, conn, fx.scope, fx.deviceID)
require.NoError(t, err)
assert.Equal(t, 2, count)
return nil
}))
}
func TestDevicePostureReport_LoadByDeviceID_IDIsCorrelationID(t *testing.T) {
t.Parallel()
client := test.PGClient(t)
ctx := context.Background()
fx := seedDevicePostureFixture(t, ctx, client)
createdAt := time.Now().UTC().Truncate(time.Microsecond)
correlationID := gid.New(fx.scope.GetTenantID(), coredata.DevicePostureReportEntityType)
insertDevicePostureWithEvidence(
t, ctx, client, fx,
"OS_VERSION",
coredata.DevicePostureStatusPass,
map[string]any{"product_version": "15.4"},
correlationID,
createdAt,
)
insertDevicePostureWithEvidence(
t, ctx, client, fx,
"DISK_ENCRYPTION",
coredata.DevicePostureStatusPass,
map[string]any{"raw": "FileVault is On."},
correlationID,
createdAt,
)
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
reports := loadDevicePostureReports(t, ctx, conn, fx, 10, nil)
require.Len(t, reports, 1)
report := reports[0]
assert.Equal(t, correlationID, report.ID)
var postures coredata.DevicePostures
require.NoError(t, postures.LoadByDeviceIDAndCorrelationIDs(
ctx, conn, fx.scope, fx.deviceID, []gid.GID{report.ID},
))
require.Len(t, postures, 2)
for _, posture := range postures {
assert.Equal(t, correlationID, posture.CorrelationID)
}
return nil
}))
}
func TestDevicePostureReport_LoadByDeviceID_PaginatesAcrossPages(t *testing.T) {
t.Parallel()
client := test.PGClient(t)
ctx := context.Background()
fx := seedDevicePostureFixture(t, ctx, client)
now := time.Now().UTC().Truncate(time.Microsecond)
createdAts := []time.Time{
now.Add(-2 * time.Hour),
now.Add(-time.Hour),
now,
}
correlationIDs := make([]gid.GID, len(createdAts))
for i := range createdAts {
correlationIDs[i] = gid.New(fx.scope.GetTenantID(), coredata.DevicePostureReportEntityType)
}
for i, createdAt := range createdAts {
insertDevicePostureWithEvidence(
t, ctx, client, fx,
"OS_VERSION",
coredata.DevicePostureStatusPass,
map[string]any{"product_version": fmt.Sprintf("15.%d", i)},
correlationIDs[i],
createdAt,
)
insertDevicePostureWithEvidence(
t, ctx, client, fx,
"DISK_ENCRYPTION",
coredata.DevicePostureStatusPass,
map[string]any{"raw": "FileVault is On."},
correlationIDs[i],
createdAt,
)
}
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
orderBy := page.OrderBy[coredata.DevicePostureReportOrderField]{
Field: coredata.DevicePostureReportOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
first := loadDevicePostureReports(t, ctx, conn, fx, 2, nil)
require.Len(t, first, 2)
assert.Equal(t, correlationIDs[2], first[0].ID)
assert.Equal(t, correlationIDs[1], first[1].ID)
assert.True(t, first[0].CreatedAt.Equal(createdAts[2]))
assert.True(t, first[1].CreatedAt.Equal(createdAts[1]))
after := first[1].CursorKey(orderBy.Field)
second := loadDevicePostureReports(t, ctx, conn, fx, 2, &after)
require.Len(t, second, 1)
assert.Equal(t, correlationIDs[0], second[0].ID)
assert.True(t, second[0].CreatedAt.Equal(createdAts[0]))
return nil
}))
}
func TestDevicePostureReport_LoadByDeviceID_IsTenantScoped(t *testing.T) {
t.Parallel()
client := test.PGClient(t)
ctx := context.Background()
fx := seedDevicePostureFixture(t, ctx, client)
other := seedDevicePostureFixture(t, ctx, client)
createdAt := time.Now().UTC().Truncate(time.Microsecond)
correlationID := gid.New(fx.scope.GetTenantID(), coredata.DevicePostureReportEntityType)
insertDevicePostureWithEvidence(
t, ctx, client, fx,
"OS_VERSION",
coredata.DevicePostureStatusPass,
map[string]any{"product_version": "15.4"},
correlationID,
createdAt,
)
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
orderBy := page.OrderBy[coredata.DevicePostureReportOrderField]{
Field: coredata.DevicePostureReportOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := page.NewCursor(10, nil, page.Head, orderBy)
var reports coredata.DevicePostureReports
require.NoError(t, reports.LoadByDeviceID(
ctx, conn, other.scope, fx.deviceID, cursor,
))
assert.Empty(t, reports)
count, err := reports.CountByDeviceID(ctx, conn, other.scope, fx.deviceID)
require.NoError(t, err)
assert.Zero(t, count)
var postures coredata.DevicePostures
require.NoError(t, postures.LoadByDeviceIDAndCorrelationIDs(
ctx, conn, other.scope, fx.deviceID, []gid.GID{correlationID},
))
assert.Empty(t, postures)
return nil
}))
}
func loadDevicePostureReports(
t *testing.T,
ctx context.Context,
conn pg.Querier,
fx devicePostureFixture,
size int,
from *page.CursorKey,
) coredata.DevicePostureReports {
t.Helper()
orderBy := page.OrderBy[coredata.DevicePostureReportOrderField]{
Field: coredata.DevicePostureReportOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := page.NewCursor(size, from, page.Head, orderBy)
var reports coredata.DevicePostureReports
require.NoError(t, reports.LoadByDeviceID(ctx, conn, fx.scope, fx.deviceID, cursor))
return page.NewPage(reports, cursor).Data
}

View File

@@ -117,6 +117,7 @@ func insertDevicePosture(
ID: gid.New(fx.scope.GetTenantID(), coredata.DevicePostureEntityType),
OrganizationID: fx.organizationID,
DeviceID: fx.deviceID,
CorrelationID: gid.New(fx.scope.GetTenantID(), coredata.DevicePostureReportEntityType),
CheckKey: checkKey,
Status: status,
ObservedAt: observedAt,

View File

@@ -0,0 +1,409 @@
// 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 coredata
import (
"encoding/json"
"strconv"
"strings"
"unicode/utf8"
)
// devicePostureValueTextMax bounds a TEXT value. Evidence literals we surface
// are short by nature (a version, an engine name, a few agent names); the cap
// only guards against a pathological host.
const devicePostureValueTextMax = 80
// DevicePostureValue is the observation a posture check made, in a shape a
// client can localize. Status is deliberately absent: whether the observation
// is acceptable is a ruleset decision, not a property of the measurement.
type DevicePostureValue struct {
Kind DevicePostureValueKind
Text string
Number *int
}
// ParseDevicePostureValue derives the observed value of a posture check from
// the evidence the agent recorded.
//
// Evidence shapes differ per platform and per tool, so each check dispatches on
// the "backend" key the agent sets (or, where it sets none, on a distinctive
// key). Unrecognised evidence yields UNKNOWN — never a guess, and never raw
// command output, which can carry usernames and file paths.
func ParseDevicePostureValue(
checkKey string,
evidence json.RawMessage,
) DevicePostureValue {
ev := decodeEvidenceMap(evidence)
if len(ev) == 0 {
return unknownValue()
}
switch DevicePostureCheckKey(checkKey) {
case DevicePostureCheckKeyOSVersion:
return parseOSVersionValue(ev)
case DevicePostureCheckKeyDiskEncryption:
return parseDiskEncryptionValue(ev)
case DevicePostureCheckKeyScreenLock:
return parseScreenLockValue(ev)
case DevicePostureCheckKeyFirewallEnabled:
return parseFirewallValue(ev)
case DevicePostureCheckKeyTimeSync:
return parseTimeSyncValue(ev)
case DevicePostureCheckKeyAutoUpdate:
return parseAutoUpdateValue(ev)
case DevicePostureCheckKeyPasswordPolicy:
return parsePasswordPolicyValue(ev)
case DevicePostureCheckKeyRemoteLogin:
return parseRemoteLoginValue(ev)
case DevicePostureCheckKeyMalwareProtection:
return parseMalwareProtectionValue(ev)
}
return unknownValue()
}
func decodeEvidenceMap(evidence json.RawMessage) map[string]any {
if len(evidence) == 0 {
return nil
}
var ev map[string]any
if err := json.Unmarshal(evidence, &ev); err != nil {
return nil
}
return ev
}
func onOffValue(on bool) DevicePostureValue {
if on {
return DevicePostureValue{Kind: DevicePostureValueKindOn}
}
return DevicePostureValue{Kind: DevicePostureValueKindOff}
}
func unknownValue() DevicePostureValue {
return DevicePostureValue{Kind: DevicePostureValueKindUnknown}
}
func noneValue() DevicePostureValue {
return DevicePostureValue{Kind: DevicePostureValueKindNone}
}
func configuredValue() DevicePostureValue {
return DevicePostureValue{Kind: DevicePostureValueKindConfigured}
}
func textValue(text string) DevicePostureValue {
text = truncateValue(text, devicePostureValueTextMax)
if text == "" {
return unknownValue()
}
return DevicePostureValue{
Kind: DevicePostureValueKindText,
Text: text,
}
}
func secondsValue(seconds int) DevicePostureValue {
return DevicePostureValue{
Kind: DevicePostureValueKindSeconds,
Number: new(seconds),
}
}
func minPasswordLengthValue(length int) DevicePostureValue {
return DevicePostureValue{
Kind: DevicePostureValueKindMinPasswordLength,
Number: new(length),
}
}
// backendOf returns the tool the agent used to gather the evidence. Checks that
// probe a single tool on every platform do not set it.
func backendOf(ev map[string]any) string {
return strings.ToLower(stringEvidence(ev, "backend"))
}
// hasAnyKey discriminates platforms for checks where the agent sets no backend
// key but the key set itself is distinctive.
func hasAnyKey(ev map[string]any, keys ...string) bool {
for _, key := range keys {
if _, ok := ev[key]; ok {
return true
}
}
return false
}
func lowerStringEvidence(ev map[string]any, key string) string {
return strings.ToLower(stringEvidence(ev, key))
}
func stringEvidence(ev map[string]any, key string) string {
v, ok := ev[key]
if !ok || v == nil {
return ""
}
switch typed := v.(type) {
case string:
return strings.TrimSpace(typed)
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case bool:
if typed {
return "true"
}
return "false"
default:
return ""
}
}
func boolEvidence(ev map[string]any, key string) (bool, bool) {
v, ok := ev[key]
if !ok || v == nil {
return false, false
}
switch typed := v.(type) {
case bool:
return typed, true
case string:
switch strings.ToLower(strings.TrimSpace(typed)) {
case "1", "true", "yes", "on":
return true, true
case "0", "false", "no", "off":
return false, true
}
case float64:
return typed != 0, true
}
return false, false
}
func numberEvidence(ev map[string]any, key string) (int, bool) {
v, ok := ev[key]
if !ok || v == nil {
return 0, false
}
switch typed := v.(type) {
case float64:
return int(typed), true
case int:
return typed, true
case string:
n, err := strconv.Atoi(strings.TrimSpace(typed))
if err != nil {
return 0, false
}
return n, true
}
return 0, false
}
func stringSliceEvidence(ev map[string]any, key string) []string {
v, ok := ev[key]
if !ok || v == nil {
return nil
}
switch typed := v.(type) {
case []string:
out := make([]string, 0, len(typed))
for _, item := range typed {
if s := strings.TrimSpace(item); s != "" {
out = append(out, s)
}
}
return out
case []any:
out := make([]string, 0, len(typed))
for _, item := range typed {
s, ok := item.(string)
if !ok {
continue
}
if s = strings.TrimSpace(s); s != "" {
out = append(out, s)
}
}
return out
default:
return nil
}
}
// stringMapEvidence reads a per-subject map such as the Windows firewall
// "profiles" or the per-user screen lock "users".
func stringMapEvidence(ev map[string]any, key string) map[string]string {
v, ok := ev[key]
if !ok || v == nil {
return nil
}
typed, ok := v.(map[string]any)
if !ok {
return nil
}
out := make(map[string]string, len(typed))
for name, raw := range typed {
s, ok := raw.(string)
if !ok {
continue
}
out[name] = strings.TrimSpace(s)
}
return out
}
// allValuesMatch reports whether the map is non-empty and every value equals
// want, case-insensitively.
func allValuesMatch(values map[string]string, want string) (allMatch bool, any bool) {
if len(values) == 0 {
return false, false
}
for _, v := range values {
if !strings.EqualFold(v, want) {
return false, true
}
}
return true, true
}
func allEntriesMatch(values []string, want string) (allMatch bool, any bool) {
if len(values) == 0 {
return false, false
}
for _, v := range values {
if !strings.EqualFold(v, want) {
return false, true
}
}
return true, true
}
func firstNonEmptyString(values ...string) string {
for _, v := range values {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
// parseLabeledInt finds lines like "Minimum password length: 8".
func parseLabeledInt(raw, label string) (int, bool) {
if raw == "" || label == "" {
return 0, false
}
lower := strings.ToLower(raw)
label = strings.ToLower(label)
idx := strings.Index(lower, label)
if idx < 0 {
return 0, false
}
rest := raw[idx+len(label):]
rest = strings.TrimLeft(rest, " \t.:")
return parseLeadingInt(rest)
}
// parseAssignedInt finds assignments like "minpasswordlen=8".
func parseAssignedInt(raw, key string) (int, bool) {
if raw == "" || key == "" {
return 0, false
}
lower := strings.ToLower(raw)
key = strings.ToLower(key) + "="
idx := strings.Index(lower, key)
if idx < 0 {
return 0, false
}
return parseLeadingInt(raw[idx+len(key):])
}
func parseLeadingInt(s string) (int, bool) {
s = strings.TrimLeft(s, " \t")
if s == "" {
return 0, false
}
end := 0
for end < len(s) && s[end] >= '0' && s[end] <= '9' {
end++
}
if end == 0 {
return 0, false
}
n, err := strconv.Atoi(s[:end])
if err != nil {
return 0, false
}
return n, true
}
// truncateValue collapses whitespace and caps the length on a rune boundary so
// the result stays valid UTF-8 for JSON encoding.
func truncateValue(v string, max int) string {
v = strings.Join(strings.Fields(v), " ")
if len(v) <= max {
return v
}
cut := max - len("…")
for cut > 0 && !utf8.RuneStart(v[cut]) {
cut--
}
return v[:cut] + "…"
}

View File

@@ -0,0 +1,584 @@
// 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 coredata
import (
"strings"
)
// One parser per posture check. Each dispatches on the tool the agent used, and
// the tools are enumerated in pkg/deviceagent/checks — read those alongside
// these branches, since they define what each backend's output means.
func parseOSVersionValue(ev map[string]any) DevicePostureValue {
return textValue(
firstNonEmptyString(
stringEvidence(ev, "product_version"),
stringEvidence(ev, "pretty_name"),
stringEvidence(ev, "version_id"),
stringEvidence(ev, "version"),
stringEvidence(ev, "caption"),
stringEvidence(ev, "release"),
),
)
}
func parseDiskEncryptionValue(ev map[string]any) DevicePostureValue {
// Linux is the only platform reporting crypttab, so the key doubles as the
// platform discriminator.
if present, ok := boolEvidence(ev, "crypttab_present"); ok {
return parseLinuxDiskEncryptionValue(ev, present)
}
raw := lowerStringEvidence(ev, "raw")
switch {
case raw == "":
return unknownValue()
case strings.Contains(raw, "filevault is on"):
return onOffValue(true)
case strings.Contains(raw, "filevault is off"):
return onOffValue(false)
case strings.Contains(raw, "percentage encrypted: 100"),
strings.Contains(raw, "fully encrypted"),
strings.Contains(raw, "protection on"):
return onOffValue(true)
case strings.Contains(raw, "percentage encrypted: 0"),
strings.Contains(raw, "fully decrypted"),
strings.Contains(raw, "protection off"):
return onOffValue(false)
case strings.Contains(raw, "components"):
// FreeBSD geli prints a "Name Status Components" table with one row per
// encrypted provider; ACTIVE is the only status meaning attached.
return onOffValue(strings.Contains(raw, "active"))
}
return unknownValue()
}
func parseLinuxDiskEncryptionValue(
ev map[string]any,
crypttabPresent bool,
) DevicePostureValue {
if crypttabPresent && len(stringSliceEvidence(ev, "crypttab_lines")) > 0 {
return onOffValue(true)
}
if lsblk := stringEvidence(ev, "lsblk"); lsblk != "" {
return onOffValue(lsblkHasCryptDevice(lsblk))
}
return unknownValue()
}
// lsblkHasCryptDevice reports whether `lsblk -o NAME,TYPE,... -r` listed a
// device of type crypt, which is how a LUKS mapping appears.
func lsblkHasCryptDevice(raw string) bool {
for line := range strings.SplitSeq(raw, "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
if fields[1] == "crypt" {
return true
}
}
return false
}
func parseScreenLockValue(ev map[string]any) DevicePostureValue {
switch backendOf(ev) {
case "sysadminctl":
return parseDarwinScreenLockModeValue(ev)
case "gnome", "cinnamon", "mate", "ukui":
return boolKeyValue(ev, "lock_enabled")
case "kde":
return boolKeyValue(ev, "autolock")
case "xfce":
return boolKeyValue(ev, "enabled")
case "i3":
return parseI3ScreenLockValue(ev)
case "machine_policy":
return boolKeyValue(ev, "screen_saver_is_secure")
case "hkey_users":
return parseWindowsUserScreenLockValue(ev)
}
// macOS falls back to the com.apple.screensaver defaults, which set no
// backend key.
if ask, ok := boolEvidence(ev, "ask_for_password"); ok {
if !ask {
return onOffValue(false)
}
if delay, ok := numberEvidence(ev, "ask_for_password_delay"); ok {
return screenLockDelayValue(delay)
}
return onOffValue(true)
}
return unknownValue()
}
func parseDarwinScreenLockModeValue(ev map[string]any) DevicePostureValue {
switch stringEvidence(ev, "mode") {
case "immediate":
return DevicePostureValue{Kind: DevicePostureValueKindImmediate}
case "off":
return onOffValue(false)
case "seconds":
if seconds, ok := numberEvidence(ev, "seconds"); ok {
return screenLockDelayValue(seconds)
}
return onOffValue(true)
}
return unknownValue()
}
func parseI3ScreenLockValue(ev map[string]any) DevicePostureValue {
if stringEvidence(ev, "error") != "" {
return unknownValue()
}
// No idle lock command in the config at all.
if stringEvidence(ev, "mechanism") == "" {
return onOffValue(false)
}
if minutes, ok := numberEvidence(ev, "idle_minutes"); ok && minutes > 0 {
return secondsValue(minutes * 60)
}
return onOffValue(true)
}
func parseWindowsUserScreenLockValue(ev map[string]any) DevicePostureValue {
allSecure, any := allValuesMatch(stringMapEvidence(ev, "users"), "1")
if !any {
return unknownValue()
}
return onOffValue(allSecure)
}
func screenLockDelayValue(seconds int) DevicePostureValue {
if seconds <= 0 {
return DevicePostureValue{Kind: DevicePostureValueKindImmediate}
}
return secondsValue(seconds)
}
func parseFirewallValue(ev map[string]any) DevicePostureValue {
switch backendOf(ev) {
case "defaults":
return parseDarwinFirewallStateValue(ev)
case "socketfilterfw":
return parseSocketFilterFWValue(ev)
case "ufw":
return parseUFWValue(ev)
case "firewalld":
return parseFirewalldValue(ev)
case "nftables":
return parseNftablesValue(ev)
case "iptables":
return parseIptablesValue(ev)
case "get-netfirewallprofile":
return parseWindowsFirewallProfilesValue(ev)
case "netsh":
return parseNetshFirewallValue(ev)
}
// FreeBSD pfctl sets no backend key.
raw := lowerStringEvidence(ev, "raw")
switch {
case strings.Contains(raw, "status: enabled"):
return onOffValue(true)
case strings.Contains(raw, "status: disabled"):
return onOffValue(false)
}
return unknownValue()
}
// parseDarwinFirewallStateValue reads com.apple.alf globalstate, where 1 blocks
// incoming connections and 2 blocks all but essential services.
func parseDarwinFirewallStateValue(ev map[string]any) DevicePostureValue {
switch stringEvidence(ev, "global_state") {
case "1", "2":
return onOffValue(true)
case "0":
return onOffValue(false)
}
return unknownValue()
}
func parseSocketFilterFWValue(ev map[string]any) DevicePostureValue {
raw := lowerStringEvidence(ev, "raw")
switch {
case strings.Contains(raw, "disabled"), strings.Contains(raw, "state = 0"):
return onOffValue(false)
case strings.Contains(raw, "enabled"),
strings.Contains(raw, "state = 1"),
strings.Contains(raw, "state = 2"):
return onOffValue(true)
}
return unknownValue()
}
// parseUFWValue reads `ufw status`. The whole "status: <state>" phrase has to
// match: "inactive" contains "active", so a bare substring test reads a
// disabled firewall as enabled.
func parseUFWValue(ev map[string]any) DevicePostureValue {
raw := lowerStringEvidence(ev, "raw")
switch {
case strings.Contains(raw, "status: inactive"):
return onOffValue(false)
case strings.Contains(raw, "status: active"):
return onOffValue(true)
}
return unknownValue()
}
// parseFirewalldValue reads `firewall-cmd --state`, which prints "running" or
// "not running" — so the negative has to be tested first.
func parseFirewalldValue(ev map[string]any) DevicePostureValue {
raw := lowerStringEvidence(ev, "raw")
switch {
case strings.Contains(raw, "not running"):
return onOffValue(false)
case strings.Contains(raw, "running"):
return onOffValue(true)
}
return unknownValue()
}
func parseNftablesValue(ev map[string]any) DevicePostureValue {
if stringEvidence(ev, "error") != "" {
return unknownValue()
}
excerpt := stringEvidence(ev, "rules_excerpt")
if excerpt == "" {
return unknownValue()
}
return onOffValue(strings.Contains(excerpt, "chain "))
}
// parseIptablesValue reads the INPUT chain default policy. An ACCEPT policy
// carrying rules cannot be classified without modelling the whole chain, which
// is what the agent declines to do as well.
func parseIptablesValue(ev map[string]any) DevicePostureValue {
if stringEvidence(ev, "error") != "" {
return unknownValue()
}
switch strings.ToUpper(stringEvidence(ev, "input_policy")) {
case "DROP", "REJECT":
return onOffValue(true)
case "ACCEPT":
if rules, ok := numberEvidence(ev, "input_rules"); ok && rules == 0 {
return onOffValue(false)
}
return unknownValue()
}
return unknownValue()
}
func parseWindowsFirewallProfilesValue(ev map[string]any) DevicePostureValue {
allEnabled, any := allValuesMatch(stringMapEvidence(ev, "profiles"), "true")
if !any {
return unknownValue()
}
return onOffValue(allEnabled)
}
func parseNetshFirewallValue(ev map[string]any) DevicePostureValue {
allOn, any := allEntriesMatch(stringSliceEvidence(ev, "state_lines"), "on")
if !any {
return unknownValue()
}
return onOffValue(allOn)
}
func parseTimeSyncValue(ev map[string]any) DevicePostureValue {
raw := lowerStringEvidence(ev, "raw")
switch {
case raw == "":
return unknownValue()
case strings.Contains(raw, "ntpsynchronized=yes"):
return onOffValue(true)
case strings.Contains(raw, "ntpsynchronized=no"):
return onOffValue(false)
case strings.Contains(raw, "network time: on"):
return onOffValue(true)
case strings.Contains(raw, "network time: off"):
return onOffValue(false)
case strings.Contains(raw, "is not running"):
return onOffValue(false)
case strings.Contains(raw, "is running"):
return onOffValue(true)
case strings.Contains(raw, "local cmos clock"):
// Windows w32tm: the local clock is not a synchronisation source.
return onOffValue(false)
case strings.Contains(raw, "source:"):
return onOffValue(true)
}
return unknownValue()
}
func parseAutoUpdateValue(ev map[string]any) DevicePostureValue {
switch backendOf(ev) {
case "defaults":
return parseDarwinSoftwareUpdateValue(ev)
case "unattended-upgrades":
// Each APT periodic task is enabled with a quoted "1".
return onOffValue(strings.Contains(stringEvidence(ev, "raw"), `"1"`))
case "dnf-automatic":
return unitStateValue(stringEvidence(ev, "state"))
}
// The Windows Update policy read sets no backend key.
if hasAnyKey(ev, "no_auto_update", "au_options", "wuauserv") {
return parseWindowsAutoUpdateValue(ev)
}
return unknownValue()
}
// parseDarwinSoftwareUpdateValue collapses the five Software Update preferences
// into one value: any preference off makes automatic updates off.
func parseDarwinSoftwareUpdateValue(ev map[string]any) DevicePostureValue {
if len(stringSliceEvidence(ev, "disabled_keys")) > 0 {
return onOffValue(false)
}
if len(stringSliceEvidence(ev, "indeterminate_keys")) > 0 {
return unknownValue()
}
return onOffValue(true)
}
func parseWindowsAutoUpdateValue(ev map[string]any) DevicePostureValue {
if stringEvidence(ev, "no_auto_update") == "1" {
return onOffValue(false)
}
// AUOptions: 2 notifies only, 3 downloads, 4 downloads and installs, 5
// delegates to local administrators.
switch stringEvidence(ev, "au_options") {
case "3", "4", "5":
return onOffValue(true)
case "2":
return onOffValue(false)
}
// With no managed policy the value is whether the Windows Update service is
// running to apply the OS default.
switch stringEvidence(ev, "wuauserv") {
case "running":
return onOffValue(true)
case "stopped":
return onOffValue(false)
}
return unknownValue()
}
func parsePasswordPolicyValue(ev map[string]any) DevicePostureValue {
// Linux reads PASS_MIN_LEN from /etc/login.defs.
if minLen, ok := numberEvidence(ev, "pass_min_len_value"); ok {
return minPasswordLengthValue(minLen)
}
if minLen, ok := numberEvidence(ev, "pass_min_len"); ok {
return minPasswordLengthValue(minLen)
}
if parseError := lowerStringEvidence(ev, "parse_error"); parseError != "" {
if strings.Contains(parseError, "not set") {
return noneValue()
}
return unknownValue()
}
// Windows `net accounts`.
if minLen, ok := parseLabeledInt(
stringEvidence(ev, "raw"),
"minimum password length",
); ok {
return minPasswordLengthValue(minLen)
}
// FreeBSD /etc/login.conf.
if snippet := stringEvidence(ev, "login_conf_snippet"); snippet != "" {
if minLen, ok := parseAssignedInt(snippet, "minpasswordlen"); ok {
return minPasswordLengthValue(minLen)
}
if strings.Contains(strings.ToLower(snippet), "passwordtime=") {
return configuredValue()
}
return noneValue()
}
// macOS pwpolicy returns the policy plist, which has no single figure.
if _, ok := ev["raw_truncated"]; ok {
raw := lowerStringEvidence(ev, "raw_truncated")
if raw == "" || strings.Contains(raw, "no account policies") {
return noneValue()
}
return configuredValue()
}
return unknownValue()
}
// parseRemoteLoginValue reports whether remote login is reachable, so On is the
// insecure observation here.
func parseRemoteLoginValue(ev map[string]any) DevicePostureValue {
// Windows: fDenyTSConnections=1 refuses Terminal Server connections.
if deny, ok := boolEvidence(ev, "fdeny_ts_connections"); ok {
return onOffValue(!deny)
}
// Linux reports the ssh unit state.
if _, ok := ev["is_active"]; ok {
return unitActiveValue(stringEvidence(ev, "is_active"))
}
raw := lowerStringEvidence(ev, "raw")
switch {
case strings.Contains(raw, "remote login: on"):
return onOffValue(true)
case strings.Contains(raw, "remote login: off"):
return onOffValue(false)
case strings.Contains(raw, "is not running"):
return onOffValue(false)
case strings.Contains(raw, "is running"):
return onOffValue(true)
}
return unknownValue()
}
func parseMalwareProtectionValue(ev map[string]any) DevicePostureValue {
// Windows Defender.
if antivirus, ok := boolEvidence(ev, "antivirus_enabled"); ok {
realtime, _ := boolEvidence(ev, "real_time_protection")
service, _ := boolEvidence(ev, "am_service_enabled")
return onOffValue(antivirus && (realtime || service))
}
// macOS XProtect. The plist path is never surfaced.
if engine := stringEvidence(ev, "engine"); engine != "" {
if strings.Contains(lowerStringEvidence(ev, "note"), "not found") {
return noneValue()
}
if version := stringEvidence(ev, "version"); version != "" {
return textValue(engine + " " + version)
}
return textValue(engine)
}
// Linux endpoint agents. Running agents are the value; agents installed but
// not running mean the protection is off.
if _, ok := ev["active"]; ok {
if active := stringSliceEvidence(ev, "active"); len(active) > 0 {
return textValue(strings.Join(active, ", "))
}
if len(stringSliceEvidence(ev, "installed")) > 0 {
return onOffValue(false)
}
return unknownValue()
}
// FreeBSD clamav.
raw := lowerStringEvidence(ev, "raw")
switch {
case strings.Contains(raw, "is not running"):
return onOffValue(false)
case strings.Contains(raw, "is running"):
return onOffValue(true)
}
if strings.Contains(lowerStringEvidence(ev, "note"), "not installed") {
return noneValue()
}
return unknownValue()
}
// unitStateValue maps `systemctl is-enabled` output.
func unitStateValue(state string) DevicePostureValue {
switch strings.ToLower(strings.TrimSpace(state)) {
case "":
return unknownValue()
case "enabled", "enabled-runtime":
return onOffValue(true)
default:
return onOffValue(false)
}
}
// unitActiveValue maps `systemctl is-active` output.
func unitActiveValue(state string) DevicePostureValue {
switch strings.ToLower(strings.TrimSpace(state)) {
case "active", "activating":
return onOffValue(true)
case "inactive", "failed", "deactivating":
return onOffValue(false)
}
return unknownValue()
}
func boolKeyValue(ev map[string]any, key string) DevicePostureValue {
v, ok := boolEvidence(ev, key)
if !ok {
return unknownValue()
}
return onOffValue(v)
}

View File

@@ -0,0 +1,121 @@
// 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 coredata
import (
"encoding"
"fmt"
)
// DevicePostureValueKind classifies the observed value of a posture check so
// clients can localize it. It says nothing about whether the observation is
// acceptable — rulesets own that verdict.
type DevicePostureValueKind string
const (
// DevicePostureValueKindOn and DevicePostureValueKindOff are boolean
// observations: the feature is turned on, or it is turned off.
DevicePostureValueKindOn DevicePostureValueKind = "ON"
DevicePostureValueKindOff DevicePostureValueKind = "OFF"
// DevicePostureValueKindImmediate is a screen lock with no grace period.
DevicePostureValueKindImmediate DevicePostureValueKind = "IMMEDIATE"
// DevicePostureValueKindSeconds carries a delay in Number.
DevicePostureValueKindSeconds DevicePostureValueKind = "SECONDS"
// DevicePostureValueKindMinPasswordLength carries a character count in
// Number.
DevicePostureValueKindMinPasswordLength DevicePostureValueKind = "MIN_PASSWORD_LENGTH"
// DevicePostureValueKindConfigured means a policy exists but its content
// could not be reduced to a single figure.
DevicePostureValueKindConfigured DevicePostureValueKind = "CONFIGURED"
// DevicePostureValueKindNone means the host positively reported the
// absence of the thing being checked.
DevicePostureValueKindNone DevicePostureValueKind = "NONE"
// DevicePostureValueKindText carries a literal in Text that needs no
// translation: an OS version, an engine name, a list of agents.
DevicePostureValueKindText DevicePostureValueKind = "TEXT"
// DevicePostureValueKindUnknown means the evidence did not answer the
// question. It is never a guess.
DevicePostureValueKindUnknown DevicePostureValueKind = "UNKNOWN"
)
var (
_ fmt.Stringer = DevicePostureValueKind("")
_ encoding.TextMarshaler = DevicePostureValueKind("")
_ encoding.TextUnmarshaler = (*DevicePostureValueKind)(nil)
)
func DevicePostureValueKinds() []DevicePostureValueKind {
return []DevicePostureValueKind{
DevicePostureValueKindOn,
DevicePostureValueKindOff,
DevicePostureValueKindImmediate,
DevicePostureValueKindSeconds,
DevicePostureValueKindMinPasswordLength,
DevicePostureValueKindConfigured,
DevicePostureValueKindNone,
DevicePostureValueKindText,
DevicePostureValueKindUnknown,
}
}
func (v DevicePostureValueKind) IsValid() bool {
switch v {
case
DevicePostureValueKindOn,
DevicePostureValueKindOff,
DevicePostureValueKindImmediate,
DevicePostureValueKindSeconds,
DevicePostureValueKindMinPasswordLength,
DevicePostureValueKindConfigured,
DevicePostureValueKindNone,
DevicePostureValueKindText,
DevicePostureValueKindUnknown:
return true
}
return false
}
func (v DevicePostureValueKind) String() string {
return string(v)
}
func (v DevicePostureValueKind) MarshalText() ([]byte, error) {
return []byte(v.String()), nil
}
func (v *DevicePostureValueKind) UnmarshalText(text []byte) error {
val := DevicePostureValueKind(text)
if !val.IsValid() {
return fmt.Errorf("invalid DevicePostureValueKind value: %q", string(text))
}
*v = val
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -139,6 +139,7 @@ const (
DeviceEntityType uint16 = 107
DevicePostureEntityType uint16 = 108
DeviceEnrollmentTokenEntityType uint16 = 109
DevicePostureReportEntityType uint16 = 110
)
func NewEntityFromID(id gid.GID) (any, bool) {
@@ -345,6 +346,8 @@ func NewEntityFromID(id gid.GID) (any, bool) {
return &DevicePosture{ID: id}, true
case DeviceEnrollmentTokenEntityType:
return &DeviceEnrollmentToken{ID: id}, true
case DevicePostureReportEntityType:
return &DevicePostureReport{ID: id}, true
default:
return nil, false
}

View File

@@ -0,0 +1,48 @@
-- 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.
ALTER TABLE device_postures
ADD COLUMN correlation_id TEXT;
WITH report_ids AS (
SELECT
device_id,
created_at,
generate_gid(parse_tenant_id(MIN(tenant_id)), 110) AS correlation_id
FROM
device_postures
GROUP BY
device_id,
created_at
)
UPDATE device_postures AS dp
SET
correlation_id = report_ids.correlation_id
FROM
report_ids
WHERE
dp.device_id = report_ids.device_id
AND dp.created_at = report_ids.created_at;
ALTER TABLE device_postures
ALTER COLUMN correlation_id SET NOT NULL;
CREATE INDEX device_postures_device_id_correlation_id_created_at_idx
ON device_postures (device_id, correlation_id, created_at DESC);

View File

@@ -28,8 +28,10 @@ import (
"time"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/deviceagent/checks"
"go.probo.inc/probo/pkg/deviceagent/update"
"go.probo.inc/probo/pkg/gid"
)
const (
@@ -450,15 +452,27 @@ func (a *Agent) doPostures(ctx context.Context) {
log.Duration("per_check_timeout", perCheckTimeout),
)
deviceID, err := gid.ParseGID(a.cfg.DeviceID)
if err != nil {
a.Logger.ErrorCtx(ctx, "cannot parse device id for posture correlation", log.Error(err))
return
}
correlationID := gid.New(
deviceID.TenantID(),
coredata.DevicePostureReportEntityType,
).String()
payload := make([]PostureResultPayload, 0, len(results))
for _, r := range results {
payload = append(
payload,
PostureResultPayload{
CheckKey: r.CheckKey,
Status: string(r.Status),
Evidence: checks.EvidenceJSON(r.Evidence),
ObservedAt: r.ObservedAt,
CheckKey: r.CheckKey,
Status: string(r.Status),
Evidence: checks.EvidenceJSON(r.Evidence),
ObservedAt: r.ObservedAt,
CorrelationID: correlationID,
},
)
}

View File

@@ -75,10 +75,11 @@ type (
}
PostureResultPayload struct {
CheckKey string `json:"check_key"`
Status string `json:"status"`
Evidence json.RawMessage `json:"evidence,omitempty"`
ObservedAt time.Time `json:"observed_at"`
CheckKey string `json:"check_key"`
Status string `json:"status"`
Evidence json.RawMessage `json:"evidence,omitempty"`
ObservedAt time.Time `json:"observed_at"`
CorrelationID string `json:"correlation_id"`
}
PosturesRequest struct {

View File

@@ -57,6 +57,18 @@ var (
// ErrEnrollmentTokenInvalid is returned when an enrollment token
// cannot be exchanged for the device.
ErrEnrollmentTokenInvalid = errors.New("enrollment token invalid")
// ErrCorrelationIDRequired is returned when a posture result is
// missing a correlation ID.
ErrCorrelationIDRequired = errors.New("correlation_id is required")
// ErrInvalidCorrelationIDEntityType is returned when a posture
// correlation ID is not a DevicePostureReport entity.
ErrInvalidCorrelationIDEntityType = errors.New("correlation_id entity type is invalid")
// ErrInvalidCorrelationIDTenant is returned when a posture
// correlation ID belongs to a different tenant than the device.
ErrInvalidCorrelationIDTenant = errors.New("correlation_id tenant is invalid")
)
const (
@@ -109,10 +121,11 @@ type (
}
RecordPostureResult struct {
CheckKey string
Status coredata.DevicePostureStatus
Evidence json.RawMessage
ObservedAt time.Time
CheckKey string
Status coredata.DevicePostureStatus
Evidence json.RawMessage
ObservedAt time.Time
CorrelationID gid.GID
}
ServiceConfig struct {
@@ -670,6 +683,113 @@ func (s *Service) GetPostureHistory(
return postures, nil
}
func (s *Service) ListPostureReports(
ctx context.Context,
scope coredata.Scoper,
deviceID gid.GID,
cursor *page.Cursor[coredata.DevicePostureReportOrderField],
) (*page.Page[*coredata.DevicePostureReport, coredata.DevicePostureReportOrderField], error) {
var result *page.Page[*coredata.DevicePostureReport, coredata.DevicePostureReportOrderField]
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var reports coredata.DevicePostureReports
if err := reports.LoadByDeviceID(ctx, conn, scope, deviceID, cursor); err != nil {
return fmt.Errorf("cannot load device posture reports: %w", err)
}
p := page.NewPage(reports, cursor)
if err := attachPosturesToReports(ctx, conn, scope, deviceID, p.Data); err != nil {
return err
}
result = p
return nil
},
)
if err != nil {
return nil, err
}
return result, nil
}
func (s *Service) CountPostureReports(
ctx context.Context,
scope coredata.Scoper,
deviceID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var reports coredata.DevicePostureReports
n, err := reports.CountByDeviceID(ctx, conn, scope, deviceID)
if err != nil {
return fmt.Errorf("cannot count device posture reports: %w", err)
}
count = n
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func attachPosturesToReports(
ctx context.Context,
conn pg.Querier,
scope coredata.Scoper,
deviceID gid.GID,
reports []*coredata.DevicePostureReport,
) error {
if len(reports) == 0 {
return nil
}
correlationIDs := make([]gid.GID, len(reports))
for i, report := range reports {
correlationIDs[i] = report.ID
report.Postures = nil
}
var postures coredata.DevicePostures
if err := postures.LoadByDeviceIDAndCorrelationIDs(
ctx,
conn,
scope,
deviceID,
correlationIDs,
); err != nil {
return fmt.Errorf("cannot load postures for reports: %w", err)
}
reportsByID := make(map[gid.GID]*coredata.DevicePostureReport, len(reports))
for _, report := range reports {
reportsByID[report.ID] = report
}
for _, posture := range postures {
report, ok := reportsByID[posture.CorrelationID]
if !ok {
continue
}
report.Postures = append(report.Postures, posture)
}
return nil
}
// AuthenticateDevice resolves a device API key to its device row.
// Returns coredata.ErrResourceNotFound when no non-revoked device
// matches the key. Revoked devices are treated as not found.
@@ -811,10 +931,23 @@ func (s *Service) RecordPostures(
}
for _, r := range results {
if r.CorrelationID == gid.Nil {
return ErrCorrelationIDRequired
}
if r.CorrelationID.EntityType() != coredata.DevicePostureReportEntityType {
return ErrInvalidCorrelationIDEntityType
}
if r.CorrelationID.TenantID() != device.ID.TenantID() {
return ErrInvalidCorrelationIDTenant
}
posture := coredata.DevicePosture{
ID: gid.New(device.OrganizationID.TenantID(), coredata.DevicePostureEntityType),
OrganizationID: device.OrganizationID,
DeviceID: device.ID,
CorrelationID: r.CorrelationID,
CheckKey: r.CheckKey,
Status: r.Status,
Evidence: r.Evidence,

View File

@@ -36,6 +36,7 @@ import (
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/bearertoken"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/itam"
"go.probo.inc/probo/pkg/server/api/agent/v1/types"
"go.probo.inc/probo/pkg/server/jsonx"
@@ -177,15 +178,33 @@ func (h *Handler) handlePostures(w http.ResponseWriter, r *http.Request) {
return
}
fallbackCorrelationID := gid.New(
dev.ID.TenantID(),
coredata.DevicePostureReportEntityType,
)
results := make([]itam.RecordPostureResult, 0, len(req.Results))
for _, pr := range req.Results {
correlationID := fallbackCorrelationID
if pr.CorrelationID != "" {
parsed, err := gid.ParseGID(pr.CorrelationID)
if err != nil {
jsonx.RenderBadRequest(w, errors.New("correlation_id is invalid"))
return
}
correlationID = parsed
}
results = append(
results,
itam.RecordPostureResult{
CheckKey: pr.CheckKey,
Status: pr.Status,
Evidence: pr.Evidence,
ObservedAt: pr.ObservedAt,
CheckKey: pr.CheckKey,
Status: pr.Status,
Evidence: pr.Evidence,
ObservedAt: pr.ObservedAt,
CorrelationID: correlationID,
},
)
}
@@ -198,6 +217,13 @@ func (h *Handler) handlePostures(w http.ResponseWriter, r *http.Request) {
return
}
if errors.Is(err, itam.ErrCorrelationIDRequired) ||
errors.Is(err, itam.ErrInvalidCorrelationIDEntityType) ||
errors.Is(err, itam.ErrInvalidCorrelationIDTenant) {
jsonx.RenderBadRequest(w, err)
return
}
h.logger.ErrorCtx(r.Context(), "cannot record postures", log.Error(err))
jsonx.RenderInternalServerError(w)

View File

@@ -52,10 +52,11 @@ type (
}
PostureResultPayload struct {
CheckKey string `json:"check_key"`
Status coredata.DevicePostureStatus `json:"status"`
Evidence json.RawMessage `json:"evidence,omitempty"`
ObservedAt time.Time `json:"observed_at"`
CheckKey string `json:"check_key"`
Status coredata.DevicePostureStatus `json:"status"`
Evidence json.RawMessage `json:"evidence,omitempty"`
ObservedAt time.Time `json:"observed_at"`
CorrelationID string `json:"correlation_id"`
}
PostureRequest struct {

View File

@@ -14,6 +14,7 @@ import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/itam"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/api/authz"
"go.probo.inc/probo/pkg/server/api/console/v1/dataloader"
@@ -64,6 +65,36 @@ func (r *deviceResolver) LatestPostures(ctx context.Context, obj *types.Device)
return types.NewDevicePostures(postures), nil
}
// PostureReports is the resolver for the postureReports field.
func (r *deviceResolver) PostureReports(ctx context.Context, obj *types.Device, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DevicePostureReportOrderBy) (*types.DevicePostureReportConnection, error) {
scope, err := r.authorize(ctx, obj.ID, itam.ActionDevicePostureList)
if err != nil {
return nil, err
}
pageOrderBy := page.OrderBy[coredata.DevicePostureReportOrderField]{
Field: coredata.DevicePostureReportOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.DevicePostureReportOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
p, err := r.itam.ListPostureReports(ctx, scope, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list device posture reports", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewDevicePostureReportConnection(p, r, obj.ID), nil
}
// TotalCount is the resolver for the DeviceConnection.totalCount field.
func (r *deviceConnectionResolver) TotalCount(ctx context.Context, obj *types.DeviceConnection) (int, error) {
scope, err := r.authorize(ctx, obj.ParentID, itam.ActionDeviceList)
@@ -85,6 +116,23 @@ func (r *deviceConnectionResolver) TotalCount(ctx context.Context, obj *types.De
return 0, gqlutils.Internal(ctx)
}
// TotalCount is the resolver for the totalCount field.
func (r *devicePostureReportConnectionResolver) TotalCount(ctx context.Context, obj *types.DevicePostureReportConnection) (int, error) {
scope, err := r.authorize(ctx, obj.ParentID, itam.ActionDevicePostureList)
if err != nil {
return 0, err
}
count, err := r.itam.CountPostureReports(ctx, scope, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count device posture reports", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
// EnrollDevice is the resolver for the enrollDevice field.
// SkipAssumptionCheck: self-enrollment from /enroll runs before the viewer
// assumes the target organization.
@@ -220,7 +268,13 @@ func (r *Resolver) DeviceConnection() schema.DeviceConnectionResolver {
return &deviceConnectionResolver{r}
}
// DevicePostureReportConnection returns schema.DevicePostureReportConnectionResolver implementation.
func (r *Resolver) DevicePostureReportConnection() schema.DevicePostureReportConnectionResolver {
return &devicePostureReportConnectionResolver{r}
}
type (
deviceResolver struct{ *Resolver }
deviceConnectionResolver struct{ *Resolver }
deviceResolver struct{ *Resolver }
deviceConnectionResolver struct{ *Resolver }
devicePostureReportConnectionResolver struct{ *Resolver }
)

View File

@@ -95,6 +95,55 @@ type Device implements Node {
owner: Profile @goField(forceResolver: true)
latestPostures: [DevicePosture!]! @goField(forceResolver: true)
postureReports(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: DevicePostureReportOrder
): DevicePostureReportConnection! @goField(forceResolver: true)
}
enum DevicePostureValueKind
@goModel(
model: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKind"
) {
ON @goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindOn")
OFF
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindOff")
IMMEDIATE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindImmediate"
)
SECONDS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindSeconds"
)
MIN_PASSWORD_LENGTH
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindMinPasswordLength"
)
CONFIGURED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindConfigured"
)
NONE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindNone"
)
TEXT
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindText")
UNKNOWN
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindUnknown"
)
}
type DevicePostureValue
@goModel(model: "go.probo.inc/probo/pkg/coredata.DevicePostureValue") {
kind: DevicePostureValueKind!
text: String!
number: Int
}
type DevicePosture implements Node {
@@ -102,9 +151,48 @@ type DevicePosture implements Node {
deviceId: ID!
checkKey: String!
status: DevicePostureStatus!
value: DevicePostureValue!
observedAt: Datetime!
}
enum DevicePostureReportOrderField
@goModel(
model: "go.probo.inc/probo/pkg/coredata.DevicePostureReportOrderField"
) {
CREATED_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DevicePostureReportOrderFieldCreatedAt"
)
}
input DevicePostureReportOrder
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DevicePostureReportOrderBy"
) {
direction: OrderDirection!
field: DevicePostureReportOrderField!
}
type DevicePostureReport {
id: ID!
createdAt: Datetime!
postures: [DevicePosture!]!
}
type DevicePostureReportConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DevicePostureReportConnection"
) {
edges: [DevicePostureReportEdge!]!
pageInfo: PageInfo!
totalCount: Int! @goField(forceResolver: true)
}
type DevicePostureReportEdge {
cursor: CursorKey!
node: DevicePostureReport!
}
type DeviceConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DeviceConnection"

View File

@@ -27,7 +27,8 @@ import (
)
type (
DeviceOrderBy OrderBy[coredata.DeviceOrderField]
DeviceOrderBy OrderBy[coredata.DeviceOrderField]
DevicePostureReportOrderBy OrderBy[coredata.DevicePostureReportOrderField]
DeviceConnection struct {
TotalCount int
@@ -47,6 +48,15 @@ type (
Cursor page.CursorKey
Node *Device
}
DevicePostureReportConnection struct {
TotalCount int
Edges []*DevicePostureReportEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
}
)
func NewDeviceConnection(
@@ -122,11 +132,14 @@ func NewDevice(d *coredata.Device) *Device {
}
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: &value,
ObservedAt: p.ObservedAt,
}
}
@@ -139,3 +152,41 @@ func NewDevicePostures(ps coredata.DevicePostures) []*DevicePosture {
return out
}
func NewDevicePostureReport(
s *coredata.DevicePostureReport,
) *DevicePostureReport {
return &DevicePostureReport{
ID: s.ID,
CreatedAt: s.CreatedAt,
Postures: NewDevicePostures(s.Postures),
}
}
func NewDevicePostureReportEdge(
s *coredata.DevicePostureReport,
orderBy coredata.DevicePostureReportOrderField,
) *DevicePostureReportEdge {
return &DevicePostureReportEdge{
Cursor: s.CursorKey(orderBy),
Node: NewDevicePostureReport(s),
}
}
func NewDevicePostureReportConnection(
p *page.Page[*coredata.DevicePostureReport, coredata.DevicePostureReportOrderField],
parentType any,
parentID gid.GID,
) *DevicePostureReportConnection {
edges := make([]*DevicePostureReportEdge, len(p.Data))
for i := range edges {
edges[i] = NewDevicePostureReportEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &DevicePostureReportConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
Resolver: parentType,
ParentID: parentID,
}
}