Add device enrollment API and agent protocol
Expose ITAM REST endpoints for agents, console GraphQL for device management, and wire probod bootstrap with enrollment e2e coverage. Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
@@ -19,18 +19,18 @@ By default every error returned to the end user **must be an opaque internal err
|
|||||||
|
|
||||||
| Category | GraphQL helper | HTTP helper | When to use |
|
| Category | GraphQL helper | HTTP helper | When to use |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Not found | `gqlutils.NotFound` / `NotFoundf` | `jsonutil.RenderNotFound` | Resource does not exist or is not visible to the caller |
|
| Not found | `gqlutils.NotFound` / `NotFoundf` | `jsonx.RenderNotFound` | Resource does not exist or is not visible to the caller |
|
||||||
| Forbidden | `gqlutils.Forbidden` / `Forbiddenf` | `jsonutil.RenderForbidden` | Caller lacks permission (after authentication) |
|
| Forbidden | `gqlutils.Forbidden` / `Forbiddenf` | `jsonx.RenderForbidden` | Caller lacks permission (after authentication) |
|
||||||
| Invalid | `gqlutils.Invalid` / `Invalidf` / `InvalidValidationErrors` | `jsonutil.RenderBadRequest` | Validation failure on user-supplied input |
|
| Invalid | `gqlutils.Invalid` / `Invalidf` / `InvalidValidationErrors` | `jsonx.RenderBadRequest` | Validation failure on user-supplied input |
|
||||||
| Conflict | `gqlutils.Conflict` / `Conflictf` | — | Unique constraint or state conflict |
|
| Conflict | `gqlutils.Conflict` / `Conflictf` | — | Unique constraint or state conflict |
|
||||||
| Unauthenticated | `gqlutils.Unauthenticated` / `Unauthenticatedf` | — | Missing or expired credentials |
|
| Unauthenticated | `gqlutils.Unauthenticated` / `Unauthenticatedf` | `jsonx.RenderUnauthorized` | Missing or expired credentials |
|
||||||
|
|
||||||
### Catch-all is always internal
|
### Catch-all is always internal
|
||||||
|
|
||||||
Any error that does **not** match one of the categories above must be returned as:
|
Any error that does **not** match one of the categories above must be returned as:
|
||||||
|
|
||||||
- **GraphQL** — `gqlutils.Internal(ctx)` (fixed generic message, no error details)
|
- **GraphQL** — `gqlutils.Internal(ctx)` (fixed generic message, no error details)
|
||||||
- **HTTP** — `jsonutil.RenderInternalServerError(w)` (fixed 500 body, no error details)
|
- **HTTP** — `jsonx.RenderInternalServerError(w)` (fixed 500 body, no error details)
|
||||||
- **MCP** — return a generic "internal error" string; never forward `err.Error()`
|
- **MCP** — return a generic "internal error" string; never forward `err.Error()`
|
||||||
|
|
||||||
Log the original error server-side (with request/trace IDs) so it can be investigated, but **never include it in the response**.
|
Log the original error server-side (with request/trace IDs) so it can be investigated, but **never include it in the response**.
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ spec:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
- name: PROBOD_AUTH_PASSWORD_ITERATIONS
|
- name: PROBOD_AUTH_PASSWORD_ITERATIONS
|
||||||
value: {{ .Values.probo.auth.passwordIterations | quote }}
|
value: {{ .Values.probo.auth.passwordIterations | quote }}
|
||||||
|
- name: PROBOD_ITAM_DEVICE_ENROLLMENT_TOKEN_VALIDITY
|
||||||
|
value: {{ .Values.probo.itam.deviceEnrollmentTokenValidity | quote }}
|
||||||
{{- if .Values.probo.saml.enabled }}
|
{{- if .Values.probo.saml.enabled }}
|
||||||
# SAML Authentication
|
# SAML Authentication
|
||||||
- name: PROBOD_SAML_SESSION_DURATION
|
- name: PROBOD_SAML_SESSION_DURATION
|
||||||
|
|||||||
@@ -234,6 +234,11 @@ probo:
|
|||||||
passwordPepper: ""
|
passwordPepper: ""
|
||||||
passwordIterations: 1000000
|
passwordIterations: 1000000
|
||||||
|
|
||||||
|
# ITAM / device agent configuration
|
||||||
|
itam:
|
||||||
|
# One-shot enrollment token validity in seconds (default: 604800 = 7 days)
|
||||||
|
deviceEnrollmentTokenValidity: 604800
|
||||||
|
|
||||||
# SAML authentication (optional)
|
# SAML authentication (optional)
|
||||||
saml:
|
saml:
|
||||||
# Enable SAML authentication
|
# Enable SAML authentication
|
||||||
|
|||||||
787
e2e/console/device_enrollment_test.go
Normal file
787
e2e/console/device_enrollment_test.go
Normal file
@@ -0,0 +1,787 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in
|
||||||
|
// all copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package console_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.probo.inc/probo/e2e/internal/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
enrollDeviceMutation = `
|
||||||
|
mutation EnrollDevice($input: EnrollDeviceInput!) {
|
||||||
|
enrollDevice(input: $input) {
|
||||||
|
enrollmentToken
|
||||||
|
serverUrl
|
||||||
|
enrollmentUrl
|
||||||
|
device { id }
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
createDeviceMutation = `
|
||||||
|
mutation CreateDevice($input: CreateDeviceInput!) {
|
||||||
|
createDevice(input: $input) {
|
||||||
|
enrollmentToken
|
||||||
|
serverUrl
|
||||||
|
enrollmentUrl
|
||||||
|
device { id }
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
revokeDeviceMutation = `
|
||||||
|
mutation RevokeDevice($input: RevokeDeviceInput!) {
|
||||||
|
revokeDevice(input: $input) {
|
||||||
|
device { id state }
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
devicePermissionQuery = `
|
||||||
|
query DevicePermission($orgId: ID!) {
|
||||||
|
node(id: $orgId) {
|
||||||
|
... on Organization {
|
||||||
|
canEnrollDevice: permission(action: "itam:device:enroll")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
getDeviceQuery = `
|
||||||
|
query GetDevice($id: ID!) {
|
||||||
|
node(id: $id) {
|
||||||
|
... on Device {
|
||||||
|
id
|
||||||
|
state
|
||||||
|
owner {
|
||||||
|
id
|
||||||
|
fullName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
listDevicesQuery = `
|
||||||
|
query ListDevices($orgId: ID!) {
|
||||||
|
node(id: $orgId) {
|
||||||
|
... on Organization {
|
||||||
|
devices(first: 1) {
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
listEnrolledDevicesQuery = `
|
||||||
|
query ListEnrolledDevices($orgId: ID!) {
|
||||||
|
viewer {
|
||||||
|
enrolledDevices(organizationId: $orgId, first: 100) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
|
||||||
|
type enrollDeviceResult struct {
|
||||||
|
EnrollDevice struct {
|
||||||
|
EnrollmentToken string `json:"enrollmentToken"`
|
||||||
|
ServerURL string `json:"serverUrl"`
|
||||||
|
EnrollmentURL string `json:"enrollmentUrl"`
|
||||||
|
Device struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"device"`
|
||||||
|
} `json:"enrollDevice"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createDeviceResult struct {
|
||||||
|
CreateDevice struct {
|
||||||
|
EnrollmentToken string `json:"enrollmentToken"`
|
||||||
|
ServerURL string `json:"serverUrl"`
|
||||||
|
EnrollmentURL string `json:"enrollmentUrl"`
|
||||||
|
Device struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"device"`
|
||||||
|
} `json:"createDevice"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type enrollAPIResponse struct {
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func exchangeEnrollmentToken(t *testing.T, token string) (int, enrollAPIResponse) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
body, err := json.Marshal(map[string]string{"token": token})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req, err := http.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
testutil.GetBaseURL()+"/api/agent/v1/enroll",
|
||||||
|
bytes.NewReader(body),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload enrollAPIResponse
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
require.NoError(t, json.Unmarshal(raw, &payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.StatusCode, payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertEnrollmentURLs(t *testing.T, serverURL, enrollmentURL, enrollmentToken string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.Equal(t, testutil.GetBaseURL(), serverURL)
|
||||||
|
|
||||||
|
parsed, err := url.Parse(enrollmentURL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "probo", parsed.Scheme)
|
||||||
|
require.Equal(t, "enroll", parsed.Host)
|
||||||
|
require.Equal(t, serverURL, parsed.Query().Get("server"))
|
||||||
|
require.Equal(t, enrollmentToken, parsed.Query().Get("token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func enrollDevice(t *testing.T, client *testutil.Client, organizationID string) enrollDeviceResult {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var result enrollDeviceResult
|
||||||
|
client.MustExecute(enrollDeviceMutation, map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"organizationId": organizationID,
|
||||||
|
},
|
||||||
|
}, &result)
|
||||||
|
require.NotEmpty(t, result.EnrollDevice.EnrollmentToken)
|
||||||
|
require.NotEmpty(t, result.EnrollDevice.Device.ID)
|
||||||
|
assertEnrollmentURLs(
|
||||||
|
t,
|
||||||
|
result.EnrollDevice.ServerURL,
|
||||||
|
result.EnrollDevice.EnrollmentURL,
|
||||||
|
result.EnrollDevice.EnrollmentToken,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func activateEnrolledDevice(t *testing.T, enrollmentToken, hardwareUUID string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
status, payload := exchangeEnrollmentToken(t, enrollmentToken)
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
require.NotEmpty(t, payload.APIKey)
|
||||||
|
|
||||||
|
body, err := json.Marshal(map[string]any{
|
||||||
|
"hardware_uuid": hardwareUUID,
|
||||||
|
"hostname": "e2e-host",
|
||||||
|
"platform": "DARWIN",
|
||||||
|
"os_version": "14.0",
|
||||||
|
"agent_version": "1.0.0",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req, err := http.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
testutil.GetBaseURL()+"/api/agent/v1/heartbeat",
|
||||||
|
bytes.NewReader(body),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", payload.APIKey))
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendHeartbeat(t *testing.T, apiKey, hardwareUUID string) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
body, err := json.Marshal(map[string]any{
|
||||||
|
"hardware_uuid": hardwareUUID,
|
||||||
|
"hostname": "e2e-host",
|
||||||
|
"platform": "DARWIN",
|
||||||
|
"os_version": "14.0",
|
||||||
|
"agent_version": "1.0.0",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req, err := http.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
testutil.GetBaseURL()+"/api/agent/v1/heartbeat",
|
||||||
|
bytes.NewReader(body),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey))
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func enrollAndActivateDevice(
|
||||||
|
t *testing.T,
|
||||||
|
client *testutil.Client,
|
||||||
|
organizationID string,
|
||||||
|
) enrollDeviceResult {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
enrolled := enrollDevice(t, client, organizationID)
|
||||||
|
activateEnrolledDevice(
|
||||||
|
t,
|
||||||
|
enrolled.EnrollDevice.EnrollmentToken,
|
||||||
|
enrolled.EnrollDevice.Device.ID+"-hw",
|
||||||
|
)
|
||||||
|
|
||||||
|
return enrolled
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDevice(
|
||||||
|
t *testing.T,
|
||||||
|
client *testutil.Client,
|
||||||
|
organizationID string,
|
||||||
|
ownerProfileID *string,
|
||||||
|
) createDeviceResult {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
input := map[string]any{
|
||||||
|
"organizationId": organizationID,
|
||||||
|
}
|
||||||
|
if ownerProfileID != nil {
|
||||||
|
input["ownerId"] = *ownerProfileID
|
||||||
|
}
|
||||||
|
|
||||||
|
var result createDeviceResult
|
||||||
|
client.MustExecute(createDeviceMutation, map[string]any{"input": input}, &result)
|
||||||
|
require.NotEmpty(t, result.CreateDevice.EnrollmentToken)
|
||||||
|
require.NotEmpty(t, result.CreateDevice.Device.ID)
|
||||||
|
assertEnrollmentURLs(
|
||||||
|
t,
|
||||||
|
result.CreateDevice.ServerURL,
|
||||||
|
result.CreateDevice.EnrollmentURL,
|
||||||
|
result.CreateDevice.EnrollmentToken,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func listEnrolledDeviceIDs(
|
||||||
|
t *testing.T,
|
||||||
|
client *testutil.Client,
|
||||||
|
organizationID string,
|
||||||
|
) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Viewer struct {
|
||||||
|
EnrolledDevices struct {
|
||||||
|
Edges []struct {
|
||||||
|
Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"node"`
|
||||||
|
} `json:"edges"`
|
||||||
|
} `json:"enrolledDevices"`
|
||||||
|
} `json:"viewer"`
|
||||||
|
}
|
||||||
|
client.MustExecute(listEnrolledDevicesQuery, map[string]any{"orgId": organizationID}, &result)
|
||||||
|
|
||||||
|
ids := make([]string, len(result.Viewer.EnrolledDevices.Edges))
|
||||||
|
for i, edge := range result.Viewer.EnrolledDevices.Edges {
|
||||||
|
ids[i] = edge.Node.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupDeviceEnrollmentClients(t *testing.T) (
|
||||||
|
owner, admin, employee, viewer *testutil.Client,
|
||||||
|
orgID, ownerProfileID string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
owner = testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
admin = testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
|
||||||
|
employee = testutil.NewClientInOrg(t, testutil.RoleEmployee, owner)
|
||||||
|
viewer = testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
||||||
|
orgID = owner.GetOrganizationID().String()
|
||||||
|
ownerProfileID = owner.GetProfileID().String()
|
||||||
|
|
||||||
|
return owner, admin, employee, viewer, orgID, ownerProfileID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceEnrollment(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("enrollment token can be exchanged once", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, _, employee, _, orgID, _ := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
enrolled := enrollDevice(t, employee, orgID)
|
||||||
|
|
||||||
|
status, payload := exchangeEnrollmentToken(t, enrolled.EnrollDevice.EnrollmentToken)
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
require.NotEmpty(t, payload.APIKey)
|
||||||
|
|
||||||
|
replayStatus, _ := exchangeEnrollmentToken(t, enrolled.EnrollDevice.EnrollmentToken)
|
||||||
|
require.Equal(t, http.StatusUnauthorized, replayStatus)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("revoked device enrollment token returns unauthorized", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner, _, employee, _, orgID, _ := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
enrolled := enrollDevice(t, employee, orgID)
|
||||||
|
|
||||||
|
owner.MustExecute(revokeDeviceMutation, map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"deviceId": enrolled.EnrollDevice.Device.ID,
|
||||||
|
},
|
||||||
|
}, &struct {
|
||||||
|
RevokeDevice struct {
|
||||||
|
Device struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
} `json:"device"`
|
||||||
|
} `json:"revokeDevice"`
|
||||||
|
}{})
|
||||||
|
|
||||||
|
status, _ := exchangeEnrollmentToken(t, enrolled.EnrollDevice.EnrollmentToken)
|
||||||
|
require.Equal(t, http.StatusUnauthorized, status)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("revoked device API key is rejected on heartbeat", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner, _, employee, _, orgID, _ := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
enrolled := enrollDevice(t, employee, orgID)
|
||||||
|
status, payload := exchangeEnrollmentToken(t, enrolled.EnrollDevice.EnrollmentToken)
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
require.NotEmpty(t, payload.APIKey)
|
||||||
|
|
||||||
|
deviceID := enrolled.EnrollDevice.Device.ID
|
||||||
|
require.Equal(t, http.StatusOK, sendHeartbeat(t, payload.APIKey, deviceID+"-hw"))
|
||||||
|
|
||||||
|
owner.MustExecute(revokeDeviceMutation, map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"deviceId": deviceID,
|
||||||
|
},
|
||||||
|
}, &struct {
|
||||||
|
RevokeDevice struct {
|
||||||
|
Device struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
} `json:"device"`
|
||||||
|
} `json:"revokeDevice"`
|
||||||
|
}{})
|
||||||
|
|
||||||
|
require.Equal(
|
||||||
|
t,
|
||||||
|
http.StatusUnauthorized,
|
||||||
|
sendHeartbeat(t, payload.APIKey, deviceID+"-hw"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("re-enrollment succeeds after revoke with same hardware UUID", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner, _, employee, _, orgID, _ := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
enrolled := enrollAndActivateDevice(t, employee, orgID)
|
||||||
|
hardwareUUID := enrolled.EnrollDevice.Device.ID + "-hw"
|
||||||
|
|
||||||
|
owner.MustExecute(revokeDeviceMutation, map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"deviceId": enrolled.EnrollDevice.Device.ID,
|
||||||
|
},
|
||||||
|
}, &struct {
|
||||||
|
RevokeDevice struct {
|
||||||
|
Device struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
} `json:"device"`
|
||||||
|
} `json:"revokeDevice"`
|
||||||
|
}{})
|
||||||
|
|
||||||
|
reEnrolled := enrollDevice(t, employee, orgID)
|
||||||
|
activateEnrolledDevice(t, reEnrolled.EnrollDevice.EnrollmentToken, hardwareUUID)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
State string `json:"state"`
|
||||||
|
} `json:"node"`
|
||||||
|
}
|
||||||
|
employee.MustExecute(getDeviceQuery, map[string]any{"id": reEnrolled.EnrollDevice.Device.ID}, &result)
|
||||||
|
require.Equal(t, reEnrolled.EnrollDevice.Device.ID, result.Node.ID)
|
||||||
|
require.Equal(t, "ACTIVE", result.Node.State)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("owner can enroll device", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner, _, _, _, orgID, _ := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
enrollDevice(t, owner, orgID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("admin can enroll device", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, admin, _, _, orgID, _ := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
enrollDevice(t, admin, orgID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("employee can enroll device", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, _, employee, _, orgID, _ := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
enrollDevice(t, employee, orgID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("employee permission gate", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, _, employee, _, orgID, _ := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Node struct {
|
||||||
|
CanEnrollDevice bool `json:"canEnrollDevice"`
|
||||||
|
} `json:"node"`
|
||||||
|
}
|
||||||
|
employee.MustExecute(devicePermissionQuery, map[string]any{"orgId": orgID}, &result)
|
||||||
|
require.True(t, result.Node.CanEnrollDevice)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("employee can read own device", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, _, employee, _, orgID, _ := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
enrolled := enrollDevice(t, employee, orgID)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
State string `json:"state"`
|
||||||
|
} `json:"node"`
|
||||||
|
}
|
||||||
|
employee.MustExecute(getDeviceQuery, map[string]any{"id": enrolled.EnrollDevice.Device.ID}, &result)
|
||||||
|
require.Equal(t, enrolled.EnrollDevice.Device.ID, result.Node.ID)
|
||||||
|
require.Equal(t, "PENDING", result.Node.State)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("employee cannot list org devices", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, _, employee, _, orgID, _ := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
_, err := employee.Do(listDevicesQuery, map[string]any{"orgId": orgID})
|
||||||
|
testutil.RequireForbiddenError(t, err, "employee should not list org devices")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("employee can list own enrolled devices", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, _, employee, _, orgID, _ := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
enrolled := enrollAndActivateDevice(t, employee, orgID)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Viewer struct {
|
||||||
|
EnrolledDevices struct {
|
||||||
|
Edges []struct {
|
||||||
|
Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
State string `json:"state"`
|
||||||
|
} `json:"node"`
|
||||||
|
} `json:"edges"`
|
||||||
|
} `json:"enrolledDevices"`
|
||||||
|
} `json:"viewer"`
|
||||||
|
}
|
||||||
|
employee.MustExecute(listEnrolledDevicesQuery, map[string]any{"orgId": orgID}, &result)
|
||||||
|
|
||||||
|
require.Len(t, result.Viewer.EnrolledDevices.Edges, 1)
|
||||||
|
require.Equal(t, enrolled.EnrollDevice.Device.ID, result.Viewer.EnrolledDevices.Edges[0].Node.ID)
|
||||||
|
require.Equal(t, "ACTIVE", result.Viewer.EnrolledDevices.Edges[0].Node.State)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("employee enrolled devices exclude pending devices", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, _, employee, _, orgID, _ := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
enrolled := enrollDevice(t, employee, orgID)
|
||||||
|
|
||||||
|
ids := listEnrolledDeviceIDs(t, employee, orgID)
|
||||||
|
require.NotContains(t, ids, enrolled.EnrollDevice.Device.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("employee only sees own enrolled devices", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner, _, _, _, orgID, _ := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
employeeA := testutil.NewClientInOrg(t, testutil.RoleEmployee, owner)
|
||||||
|
employeeB := testutil.NewClientInOrg(t, testutil.RoleEmployee, owner)
|
||||||
|
employeeBID := employeeB.GetProfileID().String()
|
||||||
|
|
||||||
|
enrolledA := enrollAndActivateDevice(t, employeeA, orgID)
|
||||||
|
createdB := createDevice(t, owner, orgID, &employeeBID)
|
||||||
|
activateEnrolledDevice(
|
||||||
|
t,
|
||||||
|
createdB.CreateDevice.EnrollmentToken,
|
||||||
|
createdB.CreateDevice.Device.ID+"-hw",
|
||||||
|
)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Viewer struct {
|
||||||
|
EnrolledDevices struct {
|
||||||
|
Edges []struct {
|
||||||
|
Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"node"`
|
||||||
|
} `json:"edges"`
|
||||||
|
} `json:"enrolledDevices"`
|
||||||
|
} `json:"viewer"`
|
||||||
|
}
|
||||||
|
employeeA.MustExecute(listEnrolledDevicesQuery, map[string]any{"orgId": orgID}, &result)
|
||||||
|
|
||||||
|
require.Len(t, result.Viewer.EnrolledDevices.Edges, 1)
|
||||||
|
require.Equal(t, enrolledA.EnrollDevice.Device.ID, result.Viewer.EnrolledDevices.Edges[0].Node.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("owner can list own enrolled devices", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner, _, _, _, orgID, _ := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
enrolled := enrollAndActivateDevice(t, owner, orgID)
|
||||||
|
|
||||||
|
ids := listEnrolledDeviceIDs(t, owner, orgID)
|
||||||
|
require.Contains(t, ids, enrolled.EnrollDevice.Device.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("admin can list own enrolled devices", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, admin, _, _, orgID, _ := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
enrolled := enrollAndActivateDevice(t, admin, orgID)
|
||||||
|
|
||||||
|
ids := listEnrolledDeviceIDs(t, admin, orgID)
|
||||||
|
require.Contains(t, ids, enrolled.EnrollDevice.Device.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("owner only sees own enrolled devices", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner, _, employee, _, orgID, _ := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
enrolledOwner := enrollAndActivateDevice(t, owner, orgID)
|
||||||
|
enrollDevice(t, employee, orgID)
|
||||||
|
|
||||||
|
ids := listEnrolledDeviceIDs(t, owner, orgID)
|
||||||
|
require.Len(t, ids, 1)
|
||||||
|
require.Equal(t, enrolledOwner.EnrollDevice.Device.ID, ids[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("employee sees device when owner was set with profile id", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner, _, _, _, orgID, _ := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
employeeA := testutil.NewClientInOrg(t, testutil.RoleEmployee, owner)
|
||||||
|
profileID := employeeA.GetProfileID().String()
|
||||||
|
|
||||||
|
created := createDevice(t, owner, orgID, &profileID)
|
||||||
|
activateEnrolledDevice(
|
||||||
|
t,
|
||||||
|
created.CreateDevice.EnrollmentToken,
|
||||||
|
created.CreateDevice.Device.ID+"-hw",
|
||||||
|
)
|
||||||
|
|
||||||
|
ids := listEnrolledDeviceIDs(t, employeeA, orgID)
|
||||||
|
require.Contains(t, ids, created.CreateDevice.Device.ID)
|
||||||
|
|
||||||
|
var deviceResult struct {
|
||||||
|
Node struct {
|
||||||
|
Owner *struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FullName string `json:"fullName"`
|
||||||
|
} `json:"owner"`
|
||||||
|
} `json:"node"`
|
||||||
|
}
|
||||||
|
owner.MustExecute(
|
||||||
|
getDeviceQuery,
|
||||||
|
map[string]any{"id": created.CreateDevice.Device.ID},
|
||||||
|
&deviceResult,
|
||||||
|
)
|
||||||
|
require.NotNil(t, deviceResult.Node.Owner)
|
||||||
|
require.Equal(t, profileID, deviceResult.Node.Owner.ID)
|
||||||
|
require.NotEmpty(t, deviceResult.Node.Owner.FullName)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("employee cannot revoke device", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, _, employee, _, orgID, _ := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
enrolled := enrollDevice(t, employee, orgID)
|
||||||
|
|
||||||
|
_, err := employee.Do(revokeDeviceMutation, map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"deviceId": enrolled.EnrollDevice.Device.ID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
testutil.RequireForbiddenError(t, err, "employee should not revoke devices")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("employee cannot create device for another user", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, _, employee, _, orgID, ownerProfileID := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
_, err := employee.Do(createDeviceMutation, map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"organizationId": orgID,
|
||||||
|
"ownerId": ownerProfileID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
testutil.RequireForbiddenError(t, err, "employee should not create device for another user")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("viewer cannot enroll device", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, _, _, viewer, orgID, _ := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
_, err := viewer.Do(enrollDeviceMutation, map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"organizationId": orgID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
testutil.RequireForbiddenError(t, err, "viewer should not enroll devices")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("owner retains admin access", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner, _, _, _, orgID, _ := setupDeviceEnrollmentClients(t)
|
||||||
|
|
||||||
|
created := createDevice(t, owner, orgID, nil)
|
||||||
|
|
||||||
|
var deviceResult struct {
|
||||||
|
Node struct {
|
||||||
|
Owner *struct {
|
||||||
|
FullName string `json:"fullName"`
|
||||||
|
} `json:"owner"`
|
||||||
|
} `json:"node"`
|
||||||
|
}
|
||||||
|
owner.MustExecute(
|
||||||
|
getDeviceQuery,
|
||||||
|
map[string]any{"id": created.CreateDevice.Device.ID},
|
||||||
|
&deviceResult,
|
||||||
|
)
|
||||||
|
require.Nil(t, deviceResult.Node.Owner)
|
||||||
|
|
||||||
|
var listResult struct {
|
||||||
|
Node struct {
|
||||||
|
Devices struct {
|
||||||
|
TotalCount int `json:"totalCount"`
|
||||||
|
} `json:"devices"`
|
||||||
|
} `json:"node"`
|
||||||
|
}
|
||||||
|
owner.MustExecute(listDevicesQuery, map[string]any{"orgId": orgID}, &listResult)
|
||||||
|
require.GreaterOrEqual(t, listResult.Node.Devices.TotalCount, 1)
|
||||||
|
|
||||||
|
var revokeResult struct {
|
||||||
|
RevokeDevice struct {
|
||||||
|
Device struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
} `json:"device"`
|
||||||
|
} `json:"revokeDevice"`
|
||||||
|
}
|
||||||
|
owner.MustExecute(revokeDeviceMutation, map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"deviceId": created.CreateDevice.Device.ID,
|
||||||
|
},
|
||||||
|
}, &revokeResult)
|
||||||
|
require.Equal(t, "REVOKED", revokeResult.RevokeDevice.Device.State)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceEnrollmentPermissionQueryShape(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
|
||||||
|
orgID := owner.GetOrganizationID().String()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
client *testutil.Client
|
||||||
|
}{
|
||||||
|
{name: "owner", client: owner},
|
||||||
|
{name: "admin", client: admin},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
resp, err := tc.client.Do(devicePermissionQuery, map[string]any{"orgId": orgID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Node struct {
|
||||||
|
CanEnrollDevice bool `json:"canEnrollDevice"`
|
||||||
|
} `json:"node"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(resp.Data, &result))
|
||||||
|
require.True(t, result.Node.CanEnrollDevice)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
4
go.mod
4
go.mod
@@ -4,6 +4,7 @@ go 1.26.5
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
codeberg.org/miekg/dns v0.6.84
|
codeberg.org/miekg/dns v0.6.84
|
||||||
|
fyne.io/systray v1.12.2
|
||||||
github.com/99designs/gqlgen v0.17.94
|
github.com/99designs/gqlgen v0.17.94
|
||||||
github.com/anthropics/anthropic-sdk-go v1.58.1
|
github.com/anthropics/anthropic-sdk-go v1.58.1
|
||||||
github.com/aws/aws-sdk-go-v2 v1.43.0
|
github.com/aws/aws-sdk-go-v2 v1.43.0
|
||||||
@@ -110,6 +111,7 @@ require (
|
|||||||
github.com/go-openapi/swag/yamlutils v0.27.3 // indirect
|
github.com/go-openapi/swag/yamlutils v0.27.3 // indirect
|
||||||
github.com/go-openapi/validate v0.26.1 // indirect
|
github.com/go-openapi/validate v0.26.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.6 // indirect
|
github.com/goccy/go-json v0.10.6 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
github.com/google/certificate-transparency-go v1.3.3 // indirect
|
github.com/google/certificate-transparency-go v1.3.3 // indirect
|
||||||
github.com/google/go-containerregistry v0.21.7 // indirect
|
github.com/google/go-containerregistry v0.21.7 // indirect
|
||||||
@@ -248,7 +250,7 @@ require (
|
|||||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||||
golang.org/x/mod v0.38.0
|
golang.org/x/mod v0.38.0
|
||||||
golang.org/x/net v0.57.0
|
golang.org/x/net v0.57.0
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0
|
||||||
golang.org/x/term v0.45.0
|
golang.org/x/term v0.45.0
|
||||||
golang.org/x/text v0.40.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
golang.org/x/tools v0.48.0 // indirect
|
golang.org/x/tools v0.48.0 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -20,6 +20,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
|||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e h1:VsUbObBMxXlc23Eb9VeeJYE4jvTs87qa5RqSN2U5FJU=
|
filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e h1:VsUbObBMxXlc23Eb9VeeJYE4jvTs87qa5RqSN2U5FJU=
|
||||||
filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e/go.mod h1:32qQ5yj3R24Eu03iWFWchdC3OB653wPvoepWejkefbY=
|
filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e/go.mod h1:32qQ5yj3R24Eu03iWFWchdC3OB653wPvoepWejkefbY=
|
||||||
|
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
|
||||||
|
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||||
github.com/99designs/gqlgen v0.17.94 h1:+3EUDVgX/8gDyDL+7NUqCo4cy2ylylwW0GvR1dGiEsA=
|
github.com/99designs/gqlgen v0.17.94 h1:+3EUDVgX/8gDyDL+7NUqCo4cy2ylylwW0GvR1dGiEsA=
|
||||||
github.com/99designs/gqlgen v0.17.94/go.mod h1:o+XaAMpPA/AX4rqeiK03tZUb/5T+WCgpRDD4aujgdas=
|
github.com/99designs/gqlgen v0.17.94/go.mod h1:o+XaAMpPA/AX4rqeiK03tZUb/5T+WCgpRDD4aujgdas=
|
||||||
github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg=
|
github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg=
|
||||||
|
|||||||
@@ -155,6 +155,12 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
ITAM: probodconfig.ITAMConfig{
|
||||||
|
DeviceEnrollmentTokenValidity: b.resolver.getEnvIntOrDefault(
|
||||||
|
"PROBOD_ITAM_DEVICE_ENROLLMENT_TOKEN_VALIDITY",
|
||||||
|
604800,
|
||||||
|
),
|
||||||
|
},
|
||||||
CompliancePortal: probodconfig.CompliancePortalConfig{
|
CompliancePortal: probodconfig.CompliancePortalConfig{
|
||||||
HTTPAddr: b.resolver.getEnv("PROBOD_TRUST_CENTER_HTTP_ADDR"),
|
HTTPAddr: b.resolver.getEnv("PROBOD_TRUST_CENTER_HTTP_ADDR"),
|
||||||
HTTPSAddr: b.resolver.getEnv("PROBOD_TRUST_CENTER_HTTPS_ADDR"),
|
HTTPSAddr: b.resolver.getEnv("PROBOD_TRUST_CENTER_HTTPS_ADDR"),
|
||||||
|
|||||||
@@ -187,6 +187,9 @@ func TestBuilder_Build_Defaults(t *testing.T) {
|
|||||||
assert.Equal(t, 60, cfg.Probod.Auth.SAML.DomainVerificationIntervalSeconds)
|
assert.Equal(t, 60, cfg.Probod.Auth.SAML.DomainVerificationIntervalSeconds)
|
||||||
assert.Empty(t, cfg.Probod.Auth.SAML.DomainVerificationResolverAddr)
|
assert.Empty(t, cfg.Probod.Auth.SAML.DomainVerificationResolverAddr)
|
||||||
|
|
||||||
|
// ITAM config
|
||||||
|
assert.Equal(t, 604800, cfg.Probod.ITAM.DeviceEnrollmentTokenValidity)
|
||||||
|
|
||||||
// Trust center config
|
// Trust center config
|
||||||
assert.Empty(t, cfg.Probod.CompliancePortal.HTTPAddr)
|
assert.Empty(t, cfg.Probod.CompliancePortal.HTTPAddr)
|
||||||
assert.Empty(t, cfg.Probod.CompliancePortal.HTTPSAddr)
|
assert.Empty(t, cfg.Probod.CompliancePortal.HTTPSAddr)
|
||||||
|
|||||||
@@ -64,5 +64,6 @@ type (
|
|||||||
PgConfig = probodconfig.PgConfig
|
PgConfig = probodconfig.PgConfig
|
||||||
SAMLConfig = probodconfig.SAMLConfig
|
SAMLConfig = probodconfig.SAMLConfig
|
||||||
SCIMBridgeConfig = probodconfig.SCIMBridgeConfig
|
SCIMBridgeConfig = probodconfig.SCIMBridgeConfig
|
||||||
|
ITAMConfig = probodconfig.ITAMConfig
|
||||||
SlackConfig = probodconfig.SlackConfig
|
SlackConfig = probodconfig.SlackConfig
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/iam/oauth2"
|
"go.probo.inc/probo/pkg/iam/oauth2"
|
||||||
"go.probo.inc/probo/pkg/iam/oauth2scope"
|
"go.probo.inc/probo/pkg/iam/oauth2scope"
|
||||||
"go.probo.inc/probo/pkg/iam/oidc"
|
"go.probo.inc/probo/pkg/iam/oidc"
|
||||||
|
"go.probo.inc/probo/pkg/itam"
|
||||||
"go.probo.inc/probo/pkg/mailer"
|
"go.probo.inc/probo/pkg/mailer"
|
||||||
"go.probo.inc/probo/pkg/mailman"
|
"go.probo.inc/probo/pkg/mailman"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
@@ -145,6 +146,9 @@ func New() *Implm {
|
|||||||
DomainVerificationResolverAddr: "8.8.8.8:53",
|
DomainVerificationResolverAddr: "8.8.8.8:53",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
ITAM: ITAMConfig{
|
||||||
|
DeviceEnrollmentTokenValidity: 604800,
|
||||||
|
},
|
||||||
CompliancePortal: CompliancePortalConfig{
|
CompliancePortal: CompliancePortalConfig{
|
||||||
HTTPAddr: ":80",
|
HTTPAddr: ":80",
|
||||||
HTTPSAddr: ":443",
|
HTTPSAddr: ":443",
|
||||||
@@ -720,6 +724,15 @@ func (impl *Implm) Run(
|
|||||||
thirdPartyService := thirdparty.NewService(pgClient, fileManagerService, thirdPartyVetter)
|
thirdPartyService := thirdparty.NewService(pgClient, fileManagerService, thirdPartyVetter)
|
||||||
riskManagementService := riskmanagement.NewService(pgClient)
|
riskManagementService := riskmanagement.NewService(pgClient)
|
||||||
|
|
||||||
|
itamService := itam.NewService(
|
||||||
|
pgClient,
|
||||||
|
iamService,
|
||||||
|
itam.ServiceConfig{
|
||||||
|
EnrollmentTokenValidity: time.Duration(impl.cfg.ITAM.DeviceEnrollmentTokenValidity) * time.Second,
|
||||||
|
},
|
||||||
|
l.Named("itam"),
|
||||||
|
)
|
||||||
|
|
||||||
serverHandler, err := server.NewServer(
|
serverHandler, err := server.NewServer(
|
||||||
server.Config{
|
server.Config{
|
||||||
AllowedOrigins: impl.cfg.Api.Cors.AllowedOrigins,
|
AllowedOrigins: impl.cfg.Api.Cors.AllowedOrigins,
|
||||||
@@ -739,6 +752,7 @@ func (impl *Implm) Run(
|
|||||||
Geoloc: geolocService,
|
Geoloc: geolocService,
|
||||||
ThirdParty: thirdPartyService,
|
ThirdParty: thirdPartyService,
|
||||||
RiskManagement: riskManagementService,
|
RiskManagement: riskManagementService,
|
||||||
|
ITAM: itamService,
|
||||||
Slack: slackService,
|
Slack: slackService,
|
||||||
ConnectorRegistry: defaultConnectorRegistry,
|
ConnectorRegistry: defaultConnectorRegistry,
|
||||||
ProviderRegistry: providerRegistry,
|
ProviderRegistry: providerRegistry,
|
||||||
@@ -955,6 +969,17 @@ func (impl *Implm) Run(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
itamGC := itam.NewGarbageCollector(pgClient, l.Named("itam"))
|
||||||
|
itamGCCtx, stopITAMGC := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
wg.Go(
|
||||||
|
func() {
|
||||||
|
if err := itamGC.Run(itamGCCtx); err != nil {
|
||||||
|
cancel(fmt.Errorf("itam garbage collector crashed: %w", err))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
esignServiceCtx, stopESignService := context.WithCancel(context.Background())
|
esignServiceCtx, stopESignService := context.WithCancel(context.Background())
|
||||||
|
|
||||||
wg.Go(
|
wg.Go(
|
||||||
@@ -1174,6 +1199,7 @@ func (impl *Implm) Run(
|
|||||||
stopExportJobExporter()
|
stopExportJobExporter()
|
||||||
stopAccessReviewWorker()
|
stopAccessReviewWorker()
|
||||||
stopIAMService()
|
stopIAMService()
|
||||||
|
stopITAMGC()
|
||||||
stopMailer()
|
stopMailer()
|
||||||
stopSlackSender()
|
stopSlackSender()
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ type (
|
|||||||
Pg PgConfig `json:"pg"`
|
Pg PgConfig `json:"pg"`
|
||||||
Api APIConfig `json:"api"`
|
Api APIConfig `json:"api"`
|
||||||
Auth AuthConfig `json:"auth"`
|
Auth AuthConfig `json:"auth"`
|
||||||
|
ITAM ITAMConfig `json:"itam"`
|
||||||
CompliancePortal CompliancePortalConfig `json:"trust-center"`
|
CompliancePortal CompliancePortalConfig `json:"trust-center"`
|
||||||
AWS AWSConfig `json:"aws"`
|
AWS AWSConfig `json:"aws"`
|
||||||
Notifications NotificationsConfig `json:"notifications"`
|
Notifications NotificationsConfig `json:"notifications"`
|
||||||
|
|||||||
25
pkg/probodconfig/itam_config.go
Normal file
25
pkg/probodconfig/itam_config.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// 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 probodconfig
|
||||||
|
|
||||||
|
type ITAMConfig struct {
|
||||||
|
DeviceEnrollmentTokenValidity int `json:"device-enrollment-token-validity"`
|
||||||
|
}
|
||||||
272
pkg/server/api/agent/v1/agent.go
Normal file
272
pkg/server/api/agent/v1/agent.go
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in
|
||||||
|
// all copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
// Package agent_v1 exposes the REST surface that the probo-agent binary
|
||||||
|
// uses to heartbeat and push device posture results.
|
||||||
|
//
|
||||||
|
// All endpoints speak JSON; agents should not need a GraphQL client.
|
||||||
|
package agent_v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"go.gearno.de/kit/httpserver"
|
||||||
|
"go.gearno.de/kit/log"
|
||||||
|
"go.probo.inc/probo/pkg/bearertoken"
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
"go.probo.inc/probo/pkg/itam"
|
||||||
|
"go.probo.inc/probo/pkg/server/api/agent/v1/types"
|
||||||
|
"go.probo.inc/probo/pkg/server/jsonx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
logger *log.Logger
|
||||||
|
itamSvc *itam.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMux(logger *log.Logger, itamSvc *itam.Service) *chi.Mux {
|
||||||
|
h := &Handler{
|
||||||
|
logger: logger,
|
||||||
|
itamSvc: itamSvc,
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Post("/enroll", h.handleEnroll)
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(h.deviceAuthMiddleware)
|
||||||
|
r.Post("/heartbeat", h.handleHeartbeat)
|
||||||
|
r.Post("/postures", h.handlePostures)
|
||||||
|
r.Post("/unenroll", h.handleUnenroll)
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleEnroll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() { _ = r.Body.Close() }()
|
||||||
|
|
||||||
|
var req types.EnrollRequest
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<14)).Decode(&req); err != nil {
|
||||||
|
jsonx.RenderBadRequest(w, fmt.Errorf("cannot decode request body: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
jsonx.RenderBadRequest(w, fmt.Errorf("invalid request body: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey, err := h.itamSvc.ExchangeEnrollmentToken(r.Context(), req.Token)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, itam.ErrEnrollmentTokenExpired),
|
||||||
|
errors.Is(err, itam.ErrEnrollmentTokenAlreadyUsed),
|
||||||
|
errors.Is(err, itam.ErrEnrollmentTokenInvalid):
|
||||||
|
jsonx.RenderUnauthorized(w, errors.New("unauthorized"))
|
||||||
|
default:
|
||||||
|
h.logger.ErrorCtx(r.Context(), "cannot exchange enrollment token", log.Error(err))
|
||||||
|
jsonx.RenderInternalServerError(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
httpserver.RenderJSON(w, http.StatusOK, types.EnrollResponse{APIKey: apiKey})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() { _ = r.Body.Close() }()
|
||||||
|
|
||||||
|
dev := deviceFromContext(r.Context())
|
||||||
|
if dev == nil {
|
||||||
|
jsonx.RenderUnauthorized(w, errors.New("unauthorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req types.HeartbeatRequest
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<14)).Decode(&req); err != nil {
|
||||||
|
jsonx.RenderBadRequest(w, fmt.Errorf("cannot decode request body: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
jsonx.RenderBadRequest(w, fmt.Errorf("invalid request body: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := coredata.NewScopeFromObjectID(dev.ID)
|
||||||
|
|
||||||
|
device, err := h.itamSvc.RecordHeartbeat(
|
||||||
|
r.Context(),
|
||||||
|
scope,
|
||||||
|
dev.ID,
|
||||||
|
itam.RecordHeartbeatRequest{
|
||||||
|
HardwareUUID: req.HardwareUUID,
|
||||||
|
SerialNumber: req.SerialNumber,
|
||||||
|
Hostname: req.Hostname,
|
||||||
|
Platform: req.Platform,
|
||||||
|
OSVersion: req.OSVersion,
|
||||||
|
AgentVersion: req.AgentVersion,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, itam.ErrDeviceRevoked) {
|
||||||
|
jsonx.RenderUnauthorized(w, errors.New("device revoked"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, itam.ErrDeviceHardwareConflict) {
|
||||||
|
jsonx.RenderBadRequest(w, errors.New("device hardware uuid already enrolled"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.ErrorCtx(r.Context(), "cannot record heartbeat", log.Error(err))
|
||||||
|
jsonx.RenderInternalServerError(w)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
httpserver.RenderJSON(w, http.StatusOK, types.NewHeartbeatResponse(device))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handlePostures(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() { _ = r.Body.Close() }()
|
||||||
|
|
||||||
|
dev := deviceFromContext(r.Context())
|
||||||
|
if dev == nil {
|
||||||
|
jsonx.RenderUnauthorized(w, errors.New("unauthorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req types.PostureRequest
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
||||||
|
jsonx.RenderBadRequest(w, fmt.Errorf("cannot decode request body: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
jsonx.RenderBadRequest(w, fmt.Errorf("invalid request body: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Results) == 0 {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]itam.RecordPostureResult, 0, len(req.Results))
|
||||||
|
for _, pr := range req.Results {
|
||||||
|
results = append(
|
||||||
|
results,
|
||||||
|
itam.RecordPostureResult{
|
||||||
|
CheckKey: pr.CheckKey,
|
||||||
|
Status: pr.Status,
|
||||||
|
Evidence: pr.Evidence,
|
||||||
|
ObservedAt: pr.ObservedAt,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := coredata.NewScopeFromObjectID(dev.ID)
|
||||||
|
|
||||||
|
if err := h.itamSvc.RecordPostures(r.Context(), scope, dev.ID, results); err != nil {
|
||||||
|
if errors.Is(err, itam.ErrDeviceRevoked) {
|
||||||
|
jsonx.RenderUnauthorized(w, errors.New("device revoked"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.ErrorCtx(r.Context(), "cannot record postures", log.Error(err))
|
||||||
|
jsonx.RenderInternalServerError(w)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleUnenroll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() { _ = r.Body.Close() }()
|
||||||
|
|
||||||
|
dev := deviceFromContext(r.Context())
|
||||||
|
if dev == nil {
|
||||||
|
jsonx.RenderUnauthorized(w, errors.New("unauthorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := coredata.NewScopeFromObjectID(dev.ID)
|
||||||
|
|
||||||
|
if err := h.itamSvc.UnenrollDevice(r.Context(), scope, dev.ID); err != nil {
|
||||||
|
h.logger.ErrorCtx(r.Context(), "cannot unenroll device", log.Error(err))
|
||||||
|
jsonx.RenderInternalServerError(w)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ctxKey struct{ name string }
|
||||||
|
|
||||||
|
var deviceContextKey = &ctxKey{name: "device"}
|
||||||
|
|
||||||
|
func deviceFromContext(ctx context.Context) *coredata.Device {
|
||||||
|
v := ctx.Value(deviceContextKey)
|
||||||
|
d, _ := v.(*coredata.Device)
|
||||||
|
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) deviceAuthMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if auth == "" {
|
||||||
|
jsonx.RenderUnauthorized(w, errors.New("missing authorization"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := bearertoken.Parse(auth)
|
||||||
|
if err != nil {
|
||||||
|
jsonx.RenderUnauthorized(w, errors.New("invalid bearer token"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dev, err := h.itamSvc.AuthenticateDevice(r.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
|
jsonx.RenderUnauthorized(w, errors.New("unauthorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.ErrorCtx(r.Context(), "cannot authenticate device", log.Error(err))
|
||||||
|
jsonx.RenderInternalServerError(w)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := contextWithDevice(r.Context(), dev)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
31
pkg/server/api/agent/v1/context.go
Normal file
31
pkg/server/api/agent/v1/context.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in
|
||||||
|
// all copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package agent_v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func contextWithDevice(ctx context.Context, device *coredata.Device) context.Context {
|
||||||
|
return context.WithValue(ctx, deviceContextKey, device)
|
||||||
|
}
|
||||||
131
pkg/server/api/agent/v1/types/models.go
Normal file
131
pkg/server/api/agent/v1/types/models.go
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.
|
||||||
|
|
||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
heartbeatIntervalSeconds = 300
|
||||||
|
postureIntervalSeconds = 3600
|
||||||
|
maxPostureResultsPerRequest = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
HeartbeatRequest struct {
|
||||||
|
HardwareUUID string `json:"hardware_uuid"`
|
||||||
|
SerialNumber *string `json:"serial_number,omitempty"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Platform coredata.DevicePlatform `json:"platform"`
|
||||||
|
OSVersion string `json:"os_version"`
|
||||||
|
AgentVersion string `json:"agent_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
HeartbeatResponse struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
HeartbeatSeconds int `json:"heartbeat_interval_seconds"`
|
||||||
|
PostureSeconds int `json:"posture_interval_seconds"`
|
||||||
|
ServerTime string `json:"server_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
PostureResultPayload struct {
|
||||||
|
CheckKey string `json:"check_key"`
|
||||||
|
Status coredata.DevicePostureStatus `json:"status"`
|
||||||
|
Evidence json.RawMessage `json:"evidence,omitempty"`
|
||||||
|
ObservedAt time.Time `json:"observed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
PostureRequest struct {
|
||||||
|
Results []PostureResultPayload `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
EnrollRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
EnrollResponse struct {
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r HeartbeatRequest) Validate() error {
|
||||||
|
if r.HardwareUUID == "" {
|
||||||
|
return errors.New("hardware_uuid is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Hostname == "" {
|
||||||
|
return errors.New("hostname is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !r.Platform.IsValid() {
|
||||||
|
return errors.New("platform is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.OSVersion == "" {
|
||||||
|
return errors.New("os_version is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.AgentVersion == "" {
|
||||||
|
return errors.New("agent_version is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHeartbeatResponse(device *coredata.Device) *HeartbeatResponse {
|
||||||
|
return &HeartbeatResponse{
|
||||||
|
DeviceID: device.ID.String(),
|
||||||
|
HeartbeatSeconds: heartbeatIntervalSeconds,
|
||||||
|
PostureSeconds: postureIntervalSeconds,
|
||||||
|
ServerTime: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r PostureRequest) Validate() error {
|
||||||
|
if len(r.Results) > maxPostureResultsPerRequest {
|
||||||
|
return errors.New("too many results")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, result := range r.Results {
|
||||||
|
if result.CheckKey == "" {
|
||||||
|
return errors.New("check_key is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Status.IsValid() {
|
||||||
|
return errors.New("status is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r EnrollRequest) Validate() error {
|
||||||
|
if r.Token == "" {
|
||||||
|
return errors.New("token is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -44,11 +44,13 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/filemanager"
|
"go.probo.inc/probo/pkg/filemanager"
|
||||||
"go.probo.inc/probo/pkg/geoloc"
|
"go.probo.inc/probo/pkg/geoloc"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"go.probo.inc/probo/pkg/iam"
|
||||||
|
"go.probo.inc/probo/pkg/itam"
|
||||||
"go.probo.inc/probo/pkg/mailman"
|
"go.probo.inc/probo/pkg/mailman"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
"go.probo.inc/probo/pkg/resourcealias"
|
"go.probo.inc/probo/pkg/resourcealias"
|
||||||
"go.probo.inc/probo/pkg/riskmanagement"
|
"go.probo.inc/probo/pkg/riskmanagement"
|
||||||
"go.probo.inc/probo/pkg/securecookie"
|
"go.probo.inc/probo/pkg/securecookie"
|
||||||
|
agent_v1 "go.probo.inc/probo/pkg/server/api/agent/v1"
|
||||||
connect_v1 "go.probo.inc/probo/pkg/server/api/connect/v1"
|
connect_v1 "go.probo.inc/probo/pkg/server/api/connect/v1"
|
||||||
console_v1 "go.probo.inc/probo/pkg/server/api/console/v1"
|
console_v1 "go.probo.inc/probo/pkg/server/api/console/v1"
|
||||||
cookiebanner_v1 "go.probo.inc/probo/pkg/server/api/cookiebanner/v1"
|
cookiebanner_v1 "go.probo.inc/probo/pkg/server/api/cookiebanner/v1"
|
||||||
@@ -80,6 +82,7 @@ type (
|
|||||||
Geoloc *geoloc.Service
|
Geoloc *geoloc.Service
|
||||||
ThirdParty *thirdparty.Service
|
ThirdParty *thirdparty.Service
|
||||||
RiskManagement *riskmanagement.Service
|
RiskManagement *riskmanagement.Service
|
||||||
|
ITAM *itam.Service
|
||||||
Cookie securecookie.Config
|
Cookie securecookie.Config
|
||||||
TokenSecret string
|
TokenSecret string
|
||||||
ConnectorRegistry *connector.ConnectorRegistry
|
ConnectorRegistry *connector.ConnectorRegistry
|
||||||
@@ -104,6 +107,7 @@ type (
|
|||||||
mcpHandler http.Handler
|
mcpHandler http.Handler
|
||||||
slackHandler http.Handler
|
slackHandler http.Handler
|
||||||
connectHandler http.Handler
|
connectHandler http.Handler
|
||||||
|
agentHandler http.Handler
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -111,6 +115,7 @@ var (
|
|||||||
ErrMissingProboService = errors.New("server configuration requires a valid probo.Service instance")
|
ErrMissingProboService = errors.New("server configuration requires a valid probo.Service instance")
|
||||||
ErrMissingIAMService = errors.New("server configuration requires a valid iam.Service instance")
|
ErrMissingIAMService = errors.New("server configuration requires a valid iam.Service instance")
|
||||||
ErrMissingSlackService = errors.New("server configuration requires a valid slack.Service instance")
|
ErrMissingSlackService = errors.New("server configuration requires a valid slack.Service instance")
|
||||||
|
ErrMissingITAMService = errors.New("server configuration requires a valid itam.Service instance")
|
||||||
)
|
)
|
||||||
|
|
||||||
func methodNotAllowed(w http.ResponseWriter, r *http.Request) {
|
func methodNotAllowed(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -150,6 +155,10 @@ func NewServer(cfg Config) (*Server, error) {
|
|||||||
return nil, ErrMissingSlackService
|
return nil, ErrMissingSlackService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.ITAM == nil {
|
||||||
|
return nil, ErrMissingITAMService
|
||||||
|
}
|
||||||
|
|
||||||
csrf := http.NewCrossOriginProtection()
|
csrf := http.NewCrossOriginProtection()
|
||||||
for _, origin := range cfg.AllowedOrigins {
|
for _, origin := range cfg.AllowedOrigins {
|
||||||
if err := csrf.AddTrustedOrigin(origin); err != nil {
|
if err := csrf.AddTrustedOrigin(origin); err != nil {
|
||||||
@@ -209,6 +218,7 @@ func NewServer(cfg Config) (*Server, error) {
|
|||||||
cfg.ThirdParty,
|
cfg.ThirdParty,
|
||||||
cfg.RiskManagement,
|
cfg.RiskManagement,
|
||||||
cfg.GraphQLLimits,
|
cfg.GraphQLLimits,
|
||||||
|
cfg.ITAM,
|
||||||
),
|
),
|
||||||
cookieBannerHandler: cookiebanner_v1.NewMux(
|
cookieBannerHandler: cookiebanner_v1.NewMux(
|
||||||
cfg.Logger.Named("cookiebanner.v1"),
|
cfg.Logger.Named("cookiebanner.v1"),
|
||||||
@@ -261,6 +271,10 @@ func NewServer(cfg Config) (*Server, error) {
|
|||||||
},
|
},
|
||||||
cfg.GraphQLLimits,
|
cfg.GraphQLLimits,
|
||||||
),
|
),
|
||||||
|
agentHandler: agent_v1.NewMux(
|
||||||
|
cfg.Logger.Named("agent.v1"),
|
||||||
|
cfg.ITAM,
|
||||||
|
),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +307,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
// list that applies to console/connect routes.
|
// list that applies to console/connect routes.
|
||||||
router.Mount("/cookie-banner/v1", http.StripPrefix("/cookie-banner/v1", s.cookieBannerHandler))
|
router.Mount("/cookie-banner/v1", http.StripPrefix("/cookie-banner/v1", s.cookieBannerHandler))
|
||||||
|
|
||||||
|
// Agent API should never be called from a browser; mount it outside
|
||||||
|
// to avoid CORS headers being set on it.
|
||||||
|
router.Mount("/agent/v1", http.StripPrefix("/agent/v1", s.agentHandler))
|
||||||
|
|
||||||
router.Group(func(r chi.Router) {
|
router.Group(func(r chi.Router) {
|
||||||
r.Use(cors.Handler(corsOpts))
|
r.Use(cors.Handler(corsOpts))
|
||||||
r.Mount("/console/v1", http.StripPrefix("/console/v1", s.consoleHandler))
|
r.Mount("/console/v1", http.StripPrefix("/console/v1", s.consoleHandler))
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/complianceportal/management"
|
"go.probo.inc/probo/pkg/complianceportal/management"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
|
"go.probo.inc/probo/pkg/itam"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
"go.probo.inc/probo/pkg/server/api/authn"
|
"go.probo.inc/probo/pkg/server/api/authn"
|
||||||
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
|
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
|
||||||
@@ -394,6 +395,16 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
|
|||||||
|
|
||||||
return types.NewAgentRun(run), nil
|
return types.NewAgentRun(run), nil
|
||||||
}
|
}
|
||||||
|
case coredata.DeviceEntityType:
|
||||||
|
action = itam.ActionDeviceGet
|
||||||
|
loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) {
|
||||||
|
device, err := r.itam.GetDevice(ctx, scope, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.NewDevice(device), nil
|
||||||
|
}
|
||||||
case coredata.AccessReviewCampaignEntityType:
|
case coredata.AccessReviewCampaignEntityType:
|
||||||
action = accessreview.ActionCampaignGet
|
action = accessreview.ActionCampaignGet
|
||||||
loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) {
|
loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) {
|
||||||
|
|||||||
239
pkg/server/api/console/v1/device_resolvers.go
Normal file
239
pkg/server/api/console/v1/device_resolvers.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
package console_v1
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.94
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/vikstrous/dataloadgen"
|
||||||
|
"go.gearno.de/kit/log"
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
"go.probo.inc/probo/pkg/iam"
|
||||||
|
"go.probo.inc/probo/pkg/itam"
|
||||||
|
"go.probo.inc/probo/pkg/server/api/authn"
|
||||||
|
"go.probo.inc/probo/pkg/server/api/authz"
|
||||||
|
"go.probo.inc/probo/pkg/server/api/console/v1/dataloader"
|
||||||
|
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
|
||||||
|
"go.probo.inc/probo/pkg/server/api/console/v1/types"
|
||||||
|
"go.probo.inc/probo/pkg/server/gqlutils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Owner is the resolver for the owner field.
|
||||||
|
func (r *deviceResolver) Owner(ctx context.Context, obj *types.Device) (*types.Profile, error) {
|
||||||
|
if obj.Owner == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := r.authorize(ctx, obj.Owner.ID, iam.ActionMembershipProfileGet); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
loaders := dataloader.FromContext(ctx)
|
||||||
|
|
||||||
|
owner, err := loaders.Profile.Load(ctx, obj.Owner.ID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) {
|
||||||
|
return nil, gqlutils.NotFound(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot get device owner profile", log.Error(err))
|
||||||
|
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.NewProfile(owner), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestPostures is the resolver for the Device.latestPostures field.
|
||||||
|
func (r *deviceResolver) LatestPostures(ctx context.Context, obj *types.Device) ([]*types.DevicePosture, error) {
|
||||||
|
scope, err := r.authorize(ctx, obj.ID, itam.ActionDevicePostureList)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
postures, err := r.itam.GetLatestPostures(ctx, scope, obj.ID)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot load latest device postures", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.NewDevicePostures(postures), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TotalCount is the resolver for the DeviceConnection.totalCount field.
|
||||||
|
func (r *deviceConnectionResolver) TotalCount(ctx context.Context, obj *types.DeviceConnection) (int, error) {
|
||||||
|
if obj.OwnerID != nil {
|
||||||
|
scope, err := r.authorize(ctx, obj.ParentID, itam.ActionEmployeeDeviceList)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := r.itam.CountForOrganizationIDAndOwnerID(ctx, scope, obj.ParentID, *obj.OwnerID)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot count devices by owner", log.Error(err))
|
||||||
|
return 0, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
scope, err := r.authorize(ctx, obj.ParentID, itam.ActionDeviceList)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch obj.Resolver.(type) {
|
||||||
|
case *organizationResolver:
|
||||||
|
count, err := r.itam.CountForOrganizationID(ctx, scope, obj.ParentID)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot count devices", log.Error(err))
|
||||||
|
return 0, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrollDevice is the resolver for the enrollDevice field.
|
||||||
|
func (r *mutationResolver) EnrollDevice(ctx context.Context, input types.EnrollDeviceInput) (*types.CreateDevicePayload, error) {
|
||||||
|
identity := authn.IdentityFromContext(ctx)
|
||||||
|
|
||||||
|
scope, err := r.authorize(
|
||||||
|
ctx,
|
||||||
|
input.OrganizationID,
|
||||||
|
itam.ActionDeviceEnroll,
|
||||||
|
authz.WithSkipAssumptionCheck(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.itam.EnrollDevice(
|
||||||
|
ctx, scope,
|
||||||
|
itam.EnrollDeviceRequest{
|
||||||
|
OrganizationID: input.OrganizationID,
|
||||||
|
IdentityID: identity.ID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
|
return nil, gqlutils.NotFound(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot enroll device", log.Error(err))
|
||||||
|
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
urls, err := buildEnrollmentURLs(r.baseURL, result.EnrollmentToken)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot build enrollment URLs", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.CreateDevicePayload{
|
||||||
|
Device: types.NewDevice(result.Device),
|
||||||
|
EnrollmentToken: result.EnrollmentToken,
|
||||||
|
ServerURL: urls.ServerURL,
|
||||||
|
EnrollmentURL: urls.EnrollmentURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDevice is the resolver for the createDevice field.
|
||||||
|
func (r *mutationResolver) CreateDevice(ctx context.Context, input types.CreateDeviceInput) (*types.CreateDevicePayload, error) {
|
||||||
|
scope, err := r.authorize(ctx, input.OrganizationID, itam.ActionDeviceCreate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.itam.CreateDevice(
|
||||||
|
ctx, scope,
|
||||||
|
itam.CreateDeviceRequest{
|
||||||
|
OrganizationID: input.OrganizationID,
|
||||||
|
OwnerID: input.OwnerID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
|
return nil, gqlutils.NotFound(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot create device", log.Error(err))
|
||||||
|
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
urls, err := buildEnrollmentURLs(r.baseURL, result.EnrollmentToken)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot build enrollment URLs", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.CreateDevicePayload{
|
||||||
|
Device: types.NewDevice(result.Device),
|
||||||
|
EnrollmentToken: result.EnrollmentToken,
|
||||||
|
ServerURL: urls.ServerURL,
|
||||||
|
EnrollmentURL: urls.EnrollmentURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeDevice is the resolver for the revokeDevice field.
|
||||||
|
func (r *mutationResolver) RevokeDevice(ctx context.Context, input types.RevokeDeviceInput) (*types.RevokeDevicePayload, error) {
|
||||||
|
scope, err := r.authorize(ctx, input.DeviceID, itam.ActionDeviceRevoke)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
d, err := r.itam.RevokeDevice(ctx, scope, input.DeviceID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
|
return nil, gqlutils.NotFound(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot revoke device", log.Error(err))
|
||||||
|
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.RevokeDevicePayload{Device: types.NewDevice(d)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDeviceOwner is the resolver for the setDeviceOwner field.
|
||||||
|
func (r *mutationResolver) SetDeviceOwner(ctx context.Context, input types.SetDeviceOwnerInput) (*types.SetDeviceOwnerPayload, error) {
|
||||||
|
scope, err := r.authorize(ctx, input.DeviceID, itam.ActionDeviceAssignOwner)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
d, err := r.itam.SetDeviceOwner(ctx, scope, input.DeviceID, input.OwnerID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
|
return nil, gqlutils.NotFound(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot set device owner", log.Error(err))
|
||||||
|
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.SetDeviceOwnerPayload{Device: types.NewDevice(d)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device returns schema.DeviceResolver implementation.
|
||||||
|
func (r *Resolver) Device() schema.DeviceResolver { return &deviceResolver{r} }
|
||||||
|
|
||||||
|
// DeviceConnection returns schema.DeviceConnectionResolver implementation.
|
||||||
|
func (r *Resolver) DeviceConnection() schema.DeviceConnectionResolver {
|
||||||
|
return &deviceConnectionResolver{r}
|
||||||
|
}
|
||||||
|
|
||||||
|
type (
|
||||||
|
deviceResolver struct{ *Resolver }
|
||||||
|
deviceConnectionResolver struct{ *Resolver }
|
||||||
|
)
|
||||||
66
pkg/server/api/console/v1/enrollment_url.go
Normal file
66
pkg/server/api/console/v1/enrollment_url.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in
|
||||||
|
// all copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package console_v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"go.probo.inc/probo/pkg/baseurl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// enrollmentURLs holds the public API origin and probo:// deep link issued
|
||||||
|
// when a device enrollment token is created.
|
||||||
|
type enrollmentURLs struct {
|
||||||
|
ServerURL string
|
||||||
|
EnrollmentURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEnrollmentURLs derives the agent server origin and deep link from the
|
||||||
|
// deployment base URL and a one-shot enrollment token.
|
||||||
|
func buildEnrollmentURLs(baseURL *baseurl.BaseURL, enrollmentToken string) (enrollmentURLs, error) {
|
||||||
|
if baseURL == nil {
|
||||||
|
return enrollmentURLs{}, fmt.Errorf("base URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if enrollmentToken == "" {
|
||||||
|
return enrollmentURLs{}, fmt.Errorf("enrollment token is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
serverURL := (&url.URL{
|
||||||
|
Scheme: baseURL.Scheme(),
|
||||||
|
Host: baseURL.Host(),
|
||||||
|
}).String()
|
||||||
|
|
||||||
|
enrollURL := &url.URL{
|
||||||
|
Scheme: "probo",
|
||||||
|
Host: "enroll",
|
||||||
|
}
|
||||||
|
query := enrollURL.Query()
|
||||||
|
query.Set("server", serverURL)
|
||||||
|
query.Set("token", enrollmentToken)
|
||||||
|
enrollURL.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
return enrollmentURLs{
|
||||||
|
ServerURL: serverURL,
|
||||||
|
EnrollmentURL: enrollURL.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
98
pkg/server/api/console/v1/enrollment_url_test.go
Normal file
98
pkg/server/api/console/v1/enrollment_url_test.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in
|
||||||
|
// all copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package console_v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.probo.inc/probo/pkg/baseurl"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildEnrollmentURLs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
baseURL string
|
||||||
|
token string
|
||||||
|
wantServerURL string
|
||||||
|
wantErrContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "strips path from base URL",
|
||||||
|
baseURL: "https://us.probo.com/console",
|
||||||
|
token: "secret-token",
|
||||||
|
wantServerURL: "https://us.probo.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "keeps non-default port",
|
||||||
|
baseURL: "http://127.0.0.1:8080/api",
|
||||||
|
token: "tok",
|
||||||
|
wantServerURL: "http://127.0.0.1:8080",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil base URL",
|
||||||
|
token: "tok",
|
||||||
|
wantErrContains: "base URL is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty token",
|
||||||
|
baseURL: "https://us.probo.com",
|
||||||
|
wantErrContains: "enrollment token is required",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var base *baseurl.BaseURL
|
||||||
|
|
||||||
|
if tt.baseURL != "" {
|
||||||
|
parsed, err := baseurl.Parse(tt.baseURL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
base = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := buildEnrollmentURLs(base, tt.token)
|
||||||
|
if tt.wantErrContains != "" {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorContains(t, err, tt.wantErrContains)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.wantServerURL, got.ServerURL)
|
||||||
|
|
||||||
|
parsed, err := url.Parse(got.EnrollmentURL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "probo", parsed.Scheme)
|
||||||
|
assert.Equal(t, "enroll", parsed.Host)
|
||||||
|
assert.Equal(t, tt.wantServerURL, parsed.Query().Get("server"))
|
||||||
|
assert.Equal(t, tt.token, parsed.Query().Get("token"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
165
pkg/server/api/console/v1/graphql/device.graphql
Normal file
165
pkg/server/api/console/v1/graphql/device.graphql
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
# of this software and associated documentation files (the "Software"), to deal
|
||||||
|
# in the Software without restriction, including without limitation the rights
|
||||||
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
# copies of the Software, and to permit persons to whom the Software is
|
||||||
|
# furnished to do so, subject to the following conditions:
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be included in
|
||||||
|
# all copies or substantial portions of the Software.
|
||||||
|
#
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
# SOFTWARE.
|
||||||
|
|
||||||
|
enum DevicePlatform
|
||||||
|
@goModel(model: "go.probo.inc/probo/pkg/coredata.DevicePlatform") {
|
||||||
|
DARWIN
|
||||||
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePlatformDarwin")
|
||||||
|
LINUX
|
||||||
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePlatformLinux")
|
||||||
|
FREEBSD
|
||||||
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePlatformFreeBSD")
|
||||||
|
WINDOWS
|
||||||
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePlatformWindows")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DeviceState
|
||||||
|
@goModel(model: "go.probo.inc/probo/pkg/coredata.DeviceState") {
|
||||||
|
PENDING
|
||||||
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DeviceStatePending")
|
||||||
|
ACTIVE
|
||||||
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DeviceStateActive")
|
||||||
|
REVOKED
|
||||||
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DeviceStateRevoked")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DevicePostureStatus
|
||||||
|
@goModel(
|
||||||
|
model: "go.probo.inc/probo/pkg/coredata.DevicePostureStatus"
|
||||||
|
) {
|
||||||
|
PASS
|
||||||
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePostureStatusPass")
|
||||||
|
FAIL
|
||||||
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePostureStatusFail")
|
||||||
|
UNKNOWN
|
||||||
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePostureStatusUnknown")
|
||||||
|
NOT_APPLICABLE
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.DevicePostureStatusNotApplicable"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DeviceOrderField
|
||||||
|
@goModel(model: "go.probo.inc/probo/pkg/coredata.DeviceOrderField") {
|
||||||
|
CREATED_AT
|
||||||
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DeviceOrderFieldCreatedAt")
|
||||||
|
UPDATED_AT
|
||||||
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DeviceOrderFieldUpdatedAt")
|
||||||
|
HOSTNAME
|
||||||
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DeviceOrderFieldHostname")
|
||||||
|
LAST_SEEN_AT
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.DeviceOrderFieldLastSeenAt"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
input DeviceOrder
|
||||||
|
@goModel(
|
||||||
|
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DeviceOrderBy"
|
||||||
|
) {
|
||||||
|
direction: OrderDirection!
|
||||||
|
field: DeviceOrderField!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Device implements Node {
|
||||||
|
id: ID!
|
||||||
|
state: DeviceState!
|
||||||
|
hostname: String
|
||||||
|
serialNumber: String
|
||||||
|
hardwareUuid: String
|
||||||
|
platform: DevicePlatform
|
||||||
|
osVersion: String
|
||||||
|
agentVersion: String
|
||||||
|
enrolledAt: Datetime
|
||||||
|
lastSeenAt: Datetime
|
||||||
|
revokedAt: Datetime
|
||||||
|
createdAt: Datetime!
|
||||||
|
updatedAt: Datetime!
|
||||||
|
|
||||||
|
owner: Profile @goField(forceResolver: true)
|
||||||
|
latestPostures: [DevicePosture!]! @goField(forceResolver: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DevicePosture implements Node {
|
||||||
|
id: ID!
|
||||||
|
deviceId: ID!
|
||||||
|
checkKey: String!
|
||||||
|
status: DevicePostureStatus!
|
||||||
|
observedAt: Datetime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceConnection
|
||||||
|
@goModel(
|
||||||
|
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DeviceConnection"
|
||||||
|
) {
|
||||||
|
edges: [DeviceEdge!]!
|
||||||
|
pageInfo: PageInfo!
|
||||||
|
totalCount: Int! @goField(forceResolver: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceEdge {
|
||||||
|
cursor: CursorKey!
|
||||||
|
node: Device!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateDevicePayload {
|
||||||
|
device: Device!
|
||||||
|
# enrollmentToken is shown ONCE; exchange via agent REST /enroll.
|
||||||
|
enrollmentToken: String!
|
||||||
|
# serverUrl is the public API origin for agent --server / deep-link server=.
|
||||||
|
serverUrl: String!
|
||||||
|
# enrollmentUrl is probo://enroll?server=...&token=... for the desktop agent.
|
||||||
|
enrollmentUrl: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type RevokeDevicePayload {
|
||||||
|
device: Device!
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetDeviceOwnerPayload {
|
||||||
|
device: Device!
|
||||||
|
}
|
||||||
|
|
||||||
|
input EnrollDeviceInput {
|
||||||
|
organizationId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateDeviceInput {
|
||||||
|
organizationId: ID!
|
||||||
|
ownerId: ID
|
||||||
|
}
|
||||||
|
|
||||||
|
input RevokeDeviceInput {
|
||||||
|
deviceId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
input SetDeviceOwnerInput {
|
||||||
|
deviceId: ID!
|
||||||
|
ownerId: ID
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
enrollDevice(input: EnrollDeviceInput!): CreateDevicePayload!
|
||||||
|
createDevice(input: CreateDeviceInput!): CreateDevicePayload!
|
||||||
|
revokeDevice(input: RevokeDeviceInput!): RevokeDevicePayload!
|
||||||
|
setDeviceOwner(
|
||||||
|
input: SetDeviceOwnerInput!
|
||||||
|
): SetDeviceOwnerPayload!
|
||||||
|
}
|
||||||
@@ -342,6 +342,14 @@ type Organization implements Node {
|
|||||||
|
|
||||||
thirdPartiesDocument: Document @goField(forceResolver: true)
|
thirdPartiesDocument: Document @goField(forceResolver: true)
|
||||||
|
|
||||||
|
devices(
|
||||||
|
first: Int
|
||||||
|
after: CursorKey
|
||||||
|
last: Int
|
||||||
|
before: CursorKey
|
||||||
|
orderBy: DeviceOrder
|
||||||
|
): DeviceConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
webhookSubscriptions(
|
webhookSubscriptions(
|
||||||
first: Int
|
first: Int
|
||||||
after: CursorKey
|
after: CursorKey
|
||||||
|
|||||||
@@ -22,4 +22,13 @@ type Viewer {
|
|||||||
): EmployeeDocumentConnection! @goField(forceResolver: true)
|
): EmployeeDocumentConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
approvableDocument(id: ID!): EmployeeDocument @goField(forceResolver: true)
|
approvableDocument(id: ID!): EmployeeDocument @goField(forceResolver: true)
|
||||||
|
|
||||||
|
enrolledDevices(
|
||||||
|
organizationId: ID!
|
||||||
|
first: Int
|
||||||
|
after: CursorKey
|
||||||
|
last: Int
|
||||||
|
before: CursorKey
|
||||||
|
orderBy: DeviceOrder
|
||||||
|
): DeviceConnection! @goField(forceResolver: true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/esign"
|
"go.probo.inc/probo/pkg/esign"
|
||||||
"go.probo.inc/probo/pkg/filemanager"
|
"go.probo.inc/probo/pkg/filemanager"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"go.probo.inc/probo/pkg/iam"
|
||||||
|
"go.probo.inc/probo/pkg/itam"
|
||||||
"go.probo.inc/probo/pkg/mailman"
|
"go.probo.inc/probo/pkg/mailman"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
"go.probo.inc/probo/pkg/resourcealias"
|
"go.probo.inc/probo/pkg/resourcealias"
|
||||||
@@ -67,6 +68,7 @@ func NewGraphQLHandler(
|
|||||||
fileManagerSvc *filemanager.Service,
|
fileManagerSvc *filemanager.Service,
|
||||||
baseURL *baseurl.BaseURL,
|
baseURL *baseurl.BaseURL,
|
||||||
limits gqlutils.Limits,
|
limits gqlutils.Limits,
|
||||||
|
itamSvc *itam.Service,
|
||||||
) http.Handler {
|
) http.Handler {
|
||||||
config := schema.Config{
|
config := schema.Config{
|
||||||
Resolvers: &Resolver{
|
Resolvers: &Resolver{
|
||||||
@@ -90,6 +92,7 @@ func NewGraphQLHandler(
|
|||||||
tokenSecret: tokenSecret,
|
tokenSecret: tokenSecret,
|
||||||
fileManager: fileManagerSvc,
|
fileManager: fileManagerSvc,
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
|
itam: itamSvc,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"go.probo.inc/probo/pkg/iam"
|
||||||
|
"go.probo.inc/probo/pkg/itam"
|
||||||
"go.probo.inc/probo/pkg/page"
|
"go.probo.inc/probo/pkg/page"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
|
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
|
||||||
@@ -1307,6 +1308,35 @@ func (r *organizationResolver) ThirdPartiesDocument(ctx context.Context, obj *ty
|
|||||||
return types.NewDocument(document), nil
|
return types.NewDocument(document), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Devices is the resolver for the devices field.
|
||||||
|
func (r *organizationResolver) Devices(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DeviceOrderBy) (*types.DeviceConnection, error) {
|
||||||
|
scope, err := r.authorize(ctx, obj.ID, itam.ActionDeviceList)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pageOrderBy := page.OrderBy[coredata.DeviceOrderField]{
|
||||||
|
Field: coredata.DeviceOrderFieldCreatedAt,
|
||||||
|
Direction: page.OrderDirectionDesc,
|
||||||
|
}
|
||||||
|
if orderBy != nil {
|
||||||
|
pageOrderBy = page.OrderBy[coredata.DeviceOrderField]{
|
||||||
|
Field: orderBy.Field,
|
||||||
|
Direction: orderBy.Direction,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||||
|
|
||||||
|
devicesPage, err := r.itam.ListForOrganizationID(ctx, scope, obj.ID, cursor)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot list organization devices", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.NewDeviceConnection(devicesPage, r, obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
// WebhookSubscriptions is the resolver for the webhookSubscriptions field.
|
// WebhookSubscriptions is the resolver for the webhookSubscriptions field.
|
||||||
func (r *organizationResolver) WebhookSubscriptions(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.WebhookSubscriptionOrderBy) (*types.WebhookSubscriptionConnection, error) {
|
func (r *organizationResolver) WebhookSubscriptions(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.WebhookSubscriptionOrderBy) (*types.WebhookSubscriptionConnection, error) {
|
||||||
scope, err := r.authorize(ctx, obj.ID, probo.ActionWebhookSubscriptionList)
|
scope, err := r.authorize(ctx, obj.ID, probo.ActionWebhookSubscriptionList)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/filemanager"
|
"go.probo.inc/probo/pkg/filemanager"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"go.probo.inc/probo/pkg/iam"
|
||||||
|
"go.probo.inc/probo/pkg/itam"
|
||||||
"go.probo.inc/probo/pkg/mailman"
|
"go.probo.inc/probo/pkg/mailman"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
"go.probo.inc/probo/pkg/resourcealias"
|
"go.probo.inc/probo/pkg/resourcealias"
|
||||||
@@ -77,6 +78,7 @@ type (
|
|||||||
providerRegistry *provider.Registry
|
providerRegistry *provider.Registry
|
||||||
riskManagement *riskmanagement.Service
|
riskManagement *riskmanagement.Service
|
||||||
thirdParty *thirdparty.Service
|
thirdParty *thirdparty.Service
|
||||||
|
itam *itam.Service
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
fileManager *filemanager.Service
|
fileManager *filemanager.Service
|
||||||
baseURL *baseurl.BaseURL
|
baseURL *baseurl.BaseURL
|
||||||
@@ -107,6 +109,7 @@ func NewMux(
|
|||||||
thirdPartySvc *thirdparty.Service,
|
thirdPartySvc *thirdparty.Service,
|
||||||
riskManagementSvc *riskmanagement.Service,
|
riskManagementSvc *riskmanagement.Service,
|
||||||
graphqlLimits gqlutils.Limits,
|
graphqlLimits gqlutils.Limits,
|
||||||
|
itamSvc *itam.Service,
|
||||||
) *chi.Mux {
|
) *chi.Mux {
|
||||||
r := chi.NewMux()
|
r := chi.NewMux()
|
||||||
|
|
||||||
@@ -133,6 +136,7 @@ func NewMux(
|
|||||||
fileManagerSvc,
|
fileManagerSvc,
|
||||||
baseURL,
|
baseURL,
|
||||||
graphqlLimits,
|
graphqlLimits,
|
||||||
|
itamSvc,
|
||||||
)
|
)
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
@@ -506,5 +510,6 @@ func isValidPagerDutySubdomain(s string) bool {
|
|||||||
|
|
||||||
func (r *Resolver) Permission(ctx context.Context, obj types.Node, action string) (bool, error) {
|
func (r *Resolver) Permission(ctx context.Context, obj types.Node, action string) (bool, error) {
|
||||||
_, err := r.authorize(ctx, obj.GetID(), action, authz.WithDryRun())
|
_, err := r.authorize(ctx, obj.GetID(), action, authz.WithDryRun())
|
||||||
|
|
||||||
return err == nil, nil
|
return err == nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
120
pkg/server/api/console/v1/types/device.go
Normal file
120
pkg/server/api/console/v1/types/device.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in
|
||||||
|
// all copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
"go.probo.inc/probo/pkg/gid"
|
||||||
|
"go.probo.inc/probo/pkg/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
DeviceOrderBy OrderBy[coredata.DeviceOrderField]
|
||||||
|
|
||||||
|
DeviceConnection struct {
|
||||||
|
TotalCount int
|
||||||
|
Edges []*DeviceEdge
|
||||||
|
PageInfo PageInfo
|
||||||
|
|
||||||
|
Resolver any
|
||||||
|
ParentID gid.GID
|
||||||
|
OwnerID *gid.GID
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewDeviceConnection(
|
||||||
|
p *page.Page[*coredata.Device, coredata.DeviceOrderField],
|
||||||
|
parentType any,
|
||||||
|
parentID gid.GID,
|
||||||
|
) *DeviceConnection {
|
||||||
|
edges := make([]*DeviceEdge, len(p.Data))
|
||||||
|
for i := range edges {
|
||||||
|
edges[i] = NewDeviceEdge(p.Data[i], p.Cursor.OrderBy.Field)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DeviceConnection{
|
||||||
|
Edges: edges,
|
||||||
|
PageInfo: *NewPageInfo(p),
|
||||||
|
Resolver: parentType,
|
||||||
|
ParentID: parentID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOwnedDeviceConnection(
|
||||||
|
p *page.Page[*coredata.Device, coredata.DeviceOrderField],
|
||||||
|
parentType any,
|
||||||
|
parentID gid.GID,
|
||||||
|
ownerID gid.GID,
|
||||||
|
) *DeviceConnection {
|
||||||
|
conn := NewDeviceConnection(p, parentType, parentID)
|
||||||
|
conn.OwnerID = &ownerID
|
||||||
|
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeviceEdge(d *coredata.Device, orderBy coredata.DeviceOrderField) *DeviceEdge {
|
||||||
|
return &DeviceEdge{
|
||||||
|
Cursor: d.CursorKey(orderBy),
|
||||||
|
Node: NewDevice(d),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDevice(d *coredata.Device) *Device {
|
||||||
|
device := &Device{
|
||||||
|
ID: d.ID,
|
||||||
|
State: d.State,
|
||||||
|
Hostname: d.Hostname,
|
||||||
|
SerialNumber: d.SerialNumber,
|
||||||
|
HardwareUUID: d.HardwareUUID,
|
||||||
|
Platform: d.Platform,
|
||||||
|
OsVersion: d.OSVersion,
|
||||||
|
AgentVersion: d.AgentVersion,
|
||||||
|
EnrolledAt: d.EnrolledAt,
|
||||||
|
LastSeenAt: d.LastSeenAt,
|
||||||
|
RevokedAt: d.RevokedAt,
|
||||||
|
CreatedAt: d.CreatedAt,
|
||||||
|
UpdatedAt: d.UpdatedAt,
|
||||||
|
}
|
||||||
|
if d.OwnerID != nil {
|
||||||
|
device.Owner = &Profile{ID: *d.OwnerID}
|
||||||
|
}
|
||||||
|
|
||||||
|
return device
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDevicePosture(p *coredata.DevicePosture) *DevicePosture {
|
||||||
|
return &DevicePosture{
|
||||||
|
ID: p.ID,
|
||||||
|
DeviceID: p.DeviceID,
|
||||||
|
CheckKey: p.CheckKey,
|
||||||
|
Status: p.Status,
|
||||||
|
ObservedAt: p.ObservedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDevicePostures(ps coredata.DevicePostures) []*DevicePosture {
|
||||||
|
out := make([]*DevicePosture, len(ps))
|
||||||
|
for i, p := range ps {
|
||||||
|
out[i] = NewDevicePosture(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
|
"go.probo.inc/probo/pkg/iam"
|
||||||
|
"go.probo.inc/probo/pkg/itam"
|
||||||
"go.probo.inc/probo/pkg/page"
|
"go.probo.inc/probo/pkg/page"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
"go.probo.inc/probo/pkg/server/api/authn"
|
"go.probo.inc/probo/pkg/server/api/authn"
|
||||||
@@ -180,6 +182,54 @@ func (r *viewerResolver) ApprovableDocument(ctx context.Context, obj *types.View
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnrolledDevices is the resolver for the enrolledDevices field.
|
||||||
|
func (r *viewerResolver) EnrolledDevices(ctx context.Context, obj *types.Viewer, organizationID gid.GID, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DeviceOrderBy) (*types.DeviceConnection, error) {
|
||||||
|
scope, err := r.authorize(ctx, organizationID, itam.ActionEmployeeDeviceList)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pageOrderBy := page.OrderBy[coredata.DeviceOrderField]{
|
||||||
|
Field: coredata.DeviceOrderFieldCreatedAt,
|
||||||
|
Direction: page.OrderDirectionDesc,
|
||||||
|
}
|
||||||
|
if orderBy != nil {
|
||||||
|
pageOrderBy = page.OrderBy[coredata.DeviceOrderField]{
|
||||||
|
Field: orderBy.Field,
|
||||||
|
Direction: orderBy.Direction,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||||
|
|
||||||
|
identity := authn.IdentityFromContext(ctx)
|
||||||
|
|
||||||
|
profile, err := r.iam.OrganizationService.GetProfileForIdentityAndOrganization(
|
||||||
|
ctx,
|
||||||
|
identity.ID,
|
||||||
|
organizationID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if _, ok := errors.AsType[*iam.ErrProfileNotFound](err); ok {
|
||||||
|
return nil, gqlutils.NotFound(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot get enrolled devices owner profile", log.Error(err))
|
||||||
|
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
devicesPage, err := r.itam.ListForOrganizationIDAndOwnerID(
|
||||||
|
ctx, scope, organizationID, profile.ID, cursor,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot list enrolled devices", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.NewOwnedDeviceConnection(devicesPage, r, organizationID, profile.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Viewer returns schema.ViewerResolver implementation.
|
// Viewer returns schema.ViewerResolver implementation.
|
||||||
func (r *Resolver) Viewer() schema.ViewerResolver { return &viewerResolver{r} }
|
func (r *Resolver) Viewer() schema.ViewerResolver { return &viewerResolver{r} }
|
||||||
|
|
||||||
|
|||||||
@@ -42,3 +42,7 @@ func RenderNotFound(w http.ResponseWriter, err error) {
|
|||||||
func RenderBadRequest(w http.ResponseWriter, err error) {
|
func RenderBadRequest(w http.ResponseWriter, err error) {
|
||||||
httpserver.RenderError(w, http.StatusBadRequest, err)
|
httpserver.RenderError(w, http.StatusBadRequest, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RenderUnauthorized(w http.ResponseWriter, err error) {
|
||||||
|
httpserver.RenderError(w, http.StatusUnauthorized, err)
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/filemanager"
|
"go.probo.inc/probo/pkg/filemanager"
|
||||||
"go.probo.inc/probo/pkg/geoloc"
|
"go.probo.inc/probo/pkg/geoloc"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"go.probo.inc/probo/pkg/iam"
|
||||||
|
"go.probo.inc/probo/pkg/itam"
|
||||||
"go.probo.inc/probo/pkg/mailman"
|
"go.probo.inc/probo/pkg/mailman"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
"go.probo.inc/probo/pkg/resourcealias"
|
"go.probo.inc/probo/pkg/resourcealias"
|
||||||
@@ -74,6 +75,7 @@ type Config struct {
|
|||||||
Geoloc *geoloc.Service
|
Geoloc *geoloc.Service
|
||||||
ThirdParty *thirdparty.Service
|
ThirdParty *thirdparty.Service
|
||||||
RiskManagement *riskmanagement.Service
|
RiskManagement *riskmanagement.Service
|
||||||
|
ITAM *itam.Service
|
||||||
Cookie securecookie.Config
|
Cookie securecookie.Config
|
||||||
TokenSecret string
|
TokenSecret string
|
||||||
ConnectorRegistry *connector.ConnectorRegistry
|
ConnectorRegistry *connector.ConnectorRegistry
|
||||||
@@ -116,6 +118,7 @@ func NewServer(cfg Config) (*Server, error) {
|
|||||||
Geoloc: cfg.Geoloc,
|
Geoloc: cfg.Geoloc,
|
||||||
ThirdParty: cfg.ThirdParty,
|
ThirdParty: cfg.ThirdParty,
|
||||||
RiskManagement: cfg.RiskManagement,
|
RiskManagement: cfg.RiskManagement,
|
||||||
|
ITAM: cfg.ITAM,
|
||||||
Cookie: cfg.Cookie,
|
Cookie: cfg.Cookie,
|
||||||
TokenSecret: cfg.TokenSecret,
|
TokenSecret: cfg.TokenSecret,
|
||||||
ConnectorRegistry: cfg.ConnectorRegistry,
|
ConnectorRegistry: cfg.ConnectorRegistry,
|
||||||
|
|||||||
Reference in New Issue
Block a user