diff --git a/contrib/claude/api-surface.md b/contrib/claude/api-surface.md index 55f557b7b..003aea4b5 100644 --- a/contrib/claude/api-surface.md +++ b/contrib/claude/api-surface.md @@ -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 | |---|---|---|---| -| Not found | `gqlutils.NotFound` / `NotFoundf` | `jsonutil.RenderNotFound` | Resource does not exist or is not visible to the caller | -| Forbidden | `gqlutils.Forbidden` / `Forbiddenf` | `jsonutil.RenderForbidden` | Caller lacks permission (after authentication) | -| Invalid | `gqlutils.Invalid` / `Invalidf` / `InvalidValidationErrors` | `jsonutil.RenderBadRequest` | Validation failure on user-supplied input | +| Not found | `gqlutils.NotFound` / `NotFoundf` | `jsonx.RenderNotFound` | Resource does not exist or is not visible to the caller | +| Forbidden | `gqlutils.Forbidden` / `Forbiddenf` | `jsonx.RenderForbidden` | Caller lacks permission (after authentication) | +| Invalid | `gqlutils.Invalid` / `Invalidf` / `InvalidValidationErrors` | `jsonx.RenderBadRequest` | Validation failure on user-supplied input | | 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 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) -- **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()` Log the original error server-side (with request/trace IDs) so it can be investigated, but **never include it in the response**. diff --git a/contrib/helm/charts/probo/templates/deployment.yaml b/contrib/helm/charts/probo/templates/deployment.yaml index bd8c9f1bb..139b67e15 100644 --- a/contrib/helm/charts/probo/templates/deployment.yaml +++ b/contrib/helm/charts/probo/templates/deployment.yaml @@ -149,6 +149,8 @@ spec: {{- end }} - name: PROBOD_AUTH_PASSWORD_ITERATIONS value: {{ .Values.probo.auth.passwordIterations | quote }} + - name: PROBOD_ITAM_DEVICE_ENROLLMENT_TOKEN_VALIDITY + value: {{ .Values.probo.itam.deviceEnrollmentTokenValidity | quote }} {{- if .Values.probo.saml.enabled }} # SAML Authentication - name: PROBOD_SAML_SESSION_DURATION diff --git a/contrib/helm/charts/probo/values.yaml b/contrib/helm/charts/probo/values.yaml index 4b73b9e77..7cb0ad6e1 100644 --- a/contrib/helm/charts/probo/values.yaml +++ b/contrib/helm/charts/probo/values.yaml @@ -234,6 +234,11 @@ probo: passwordPepper: "" passwordIterations: 1000000 + # ITAM / device agent configuration + itam: + # One-shot enrollment token validity in seconds (default: 604800 = 7 days) + deviceEnrollmentTokenValidity: 604800 + # SAML authentication (optional) saml: # Enable SAML authentication diff --git a/e2e/console/device_enrollment_test.go b/e2e/console/device_enrollment_test.go new file mode 100644 index 000000000..734207894 --- /dev/null +++ b/e2e/console/device_enrollment_test.go @@ -0,0 +1,787 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) + }) + } +} diff --git a/go.mod b/go.mod index a58c110bf..a37c91aa4 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.5 require ( codeberg.org/miekg/dns v0.6.84 + fyne.io/systray v1.12.2 github.com/99designs/gqlgen v0.17.94 github.com/anthropics/anthropic-sdk-go v1.58.1 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/validate v0.26.1 // 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/google/certificate-transparency-go v1.3.3 // indirect github.com/google/go-containerregistry v0.21.7 // indirect @@ -248,7 +250,7 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect golang.org/x/mod v0.38.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/text v0.40.0 // indirect golang.org/x/tools v0.48.0 // indirect diff --git a/go.sum b/go.sum index edff15a5e..1ff329f7d 100644 --- a/go.sum +++ b/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/mldsa v0.0.0-20260215214346-43d0283efc3e h1:VsUbObBMxXlc23Eb9VeeJYE4jvTs87qa5RqSN2U5FJU= 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/go.mod h1:o+XaAMpPA/AX4rqeiK03tZUb/5T+WCgpRDD4aujgdas= github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= diff --git a/pkg/bootstrap/builder.go b/pkg/bootstrap/builder.go index 253fa90c9..6516c9a62 100644 --- a/pkg/bootstrap/builder.go +++ b/pkg/bootstrap/builder.go @@ -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{ HTTPAddr: b.resolver.getEnv("PROBOD_TRUST_CENTER_HTTP_ADDR"), HTTPSAddr: b.resolver.getEnv("PROBOD_TRUST_CENTER_HTTPS_ADDR"), diff --git a/pkg/bootstrap/builder_test.go b/pkg/bootstrap/builder_test.go index 1525a28b6..351d5f836 100644 --- a/pkg/bootstrap/builder_test.go +++ b/pkg/bootstrap/builder_test.go @@ -187,6 +187,9 @@ func TestBuilder_Build_Defaults(t *testing.T) { assert.Equal(t, 60, cfg.Probod.Auth.SAML.DomainVerificationIntervalSeconds) assert.Empty(t, cfg.Probod.Auth.SAML.DomainVerificationResolverAddr) + // ITAM config + assert.Equal(t, 604800, cfg.Probod.ITAM.DeviceEnrollmentTokenValidity) + // Trust center config assert.Empty(t, cfg.Probod.CompliancePortal.HTTPAddr) assert.Empty(t, cfg.Probod.CompliancePortal.HTTPSAddr) diff --git a/pkg/probod/aliases.go b/pkg/probod/aliases.go index cd8e05ba7..14b784d1c 100644 --- a/pkg/probod/aliases.go +++ b/pkg/probod/aliases.go @@ -64,5 +64,6 @@ type ( PgConfig = probodconfig.PgConfig SAMLConfig = probodconfig.SAMLConfig SCIMBridgeConfig = probodconfig.SCIMBridgeConfig + ITAMConfig = probodconfig.ITAMConfig SlackConfig = probodconfig.SlackConfig ) diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 35dc861bd..0d8f193d5 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -71,6 +71,7 @@ import ( "go.probo.inc/probo/pkg/iam/oauth2" "go.probo.inc/probo/pkg/iam/oauth2scope" "go.probo.inc/probo/pkg/iam/oidc" + "go.probo.inc/probo/pkg/itam" "go.probo.inc/probo/pkg/mailer" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" @@ -145,6 +146,9 @@ func New() *Implm { DomainVerificationResolverAddr: "8.8.8.8:53", }, }, + ITAM: ITAMConfig{ + DeviceEnrollmentTokenValidity: 604800, + }, CompliancePortal: CompliancePortalConfig{ HTTPAddr: ":80", HTTPSAddr: ":443", @@ -720,6 +724,15 @@ func (impl *Implm) Run( thirdPartyService := thirdparty.NewService(pgClient, fileManagerService, thirdPartyVetter) 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( server.Config{ AllowedOrigins: impl.cfg.Api.Cors.AllowedOrigins, @@ -739,6 +752,7 @@ func (impl *Implm) Run( Geoloc: geolocService, ThirdParty: thirdPartyService, RiskManagement: riskManagementService, + ITAM: itamService, Slack: slackService, ConnectorRegistry: defaultConnectorRegistry, 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()) wg.Go( @@ -1174,6 +1199,7 @@ func (impl *Implm) Run( stopExportJobExporter() stopAccessReviewWorker() stopIAMService() + stopITAMGC() stopMailer() stopSlackSender() diff --git a/pkg/probodconfig/config.go b/pkg/probodconfig/config.go index 16dae09ee..f5c2fbd69 100644 --- a/pkg/probodconfig/config.go +++ b/pkg/probodconfig/config.go @@ -60,6 +60,7 @@ type ( Pg PgConfig `json:"pg"` Api APIConfig `json:"api"` Auth AuthConfig `json:"auth"` + ITAM ITAMConfig `json:"itam"` CompliancePortal CompliancePortalConfig `json:"trust-center"` AWS AWSConfig `json:"aws"` Notifications NotificationsConfig `json:"notifications"` diff --git a/pkg/probodconfig/itam_config.go b/pkg/probodconfig/itam_config.go new file mode 100644 index 000000000..032c49399 --- /dev/null +++ b/pkg/probodconfig/itam_config.go @@ -0,0 +1,25 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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"` +} diff --git a/pkg/server/api/agent/v1/agent.go b/pkg/server/api/agent/v1/agent.go new file mode 100644 index 000000000..a32b54213 --- /dev/null +++ b/pkg/server/api/agent/v1/agent.go @@ -0,0 +1,272 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// 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)) + }) +} diff --git a/pkg/server/api/agent/v1/context.go b/pkg/server/api/agent/v1/context.go new file mode 100644 index 000000000..f3303d75b --- /dev/null +++ b/pkg/server/api/agent/v1/context.go @@ -0,0 +1,31 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// 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) +} diff --git a/pkg/server/api/agent/v1/types/models.go b/pkg/server/api/agent/v1/types/models.go new file mode 100644 index 000000000..55896c7dd --- /dev/null +++ b/pkg/server/api/agent/v1/types/models.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/server/api/api.go b/pkg/server/api/api.go index 6b5e4a3db..d96f28ba8 100644 --- a/pkg/server/api/api.go +++ b/pkg/server/api/api.go @@ -44,11 +44,13 @@ import ( "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/geoloc" "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/itam" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/resourcealias" "go.probo.inc/probo/pkg/riskmanagement" "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" console_v1 "go.probo.inc/probo/pkg/server/api/console/v1" cookiebanner_v1 "go.probo.inc/probo/pkg/server/api/cookiebanner/v1" @@ -80,6 +82,7 @@ type ( Geoloc *geoloc.Service ThirdParty *thirdparty.Service RiskManagement *riskmanagement.Service + ITAM *itam.Service Cookie securecookie.Config TokenSecret string ConnectorRegistry *connector.ConnectorRegistry @@ -104,6 +107,7 @@ type ( mcpHandler http.Handler slackHandler http.Handler connectHandler http.Handler + agentHandler http.Handler } ) @@ -111,6 +115,7 @@ var ( ErrMissingProboService = errors.New("server configuration requires a valid probo.Service instance") ErrMissingIAMService = errors.New("server configuration requires a valid iam.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) { @@ -150,6 +155,10 @@ func NewServer(cfg Config) (*Server, error) { return nil, ErrMissingSlackService } + if cfg.ITAM == nil { + return nil, ErrMissingITAMService + } + csrf := http.NewCrossOriginProtection() for _, origin := range cfg.AllowedOrigins { if err := csrf.AddTrustedOrigin(origin); err != nil { @@ -209,6 +218,7 @@ func NewServer(cfg Config) (*Server, error) { cfg.ThirdParty, cfg.RiskManagement, cfg.GraphQLLimits, + cfg.ITAM, ), cookieBannerHandler: cookiebanner_v1.NewMux( cfg.Logger.Named("cookiebanner.v1"), @@ -261,6 +271,10 @@ func NewServer(cfg Config) (*Server, error) { }, cfg.GraphQLLimits, ), + agentHandler: agent_v1.NewMux( + cfg.Logger.Named("agent.v1"), + cfg.ITAM, + ), }, nil } @@ -293,6 +307,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // list that applies to console/connect routes. 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) { r.Use(cors.Handler(corsOpts)) r.Mount("/console/v1", http.StripPrefix("/console/v1", s.consoleHandler)) diff --git a/pkg/server/api/console/v1/base_resolvers.go b/pkg/server/api/console/v1/base_resolvers.go index 7a9894548..62bdfda6c 100644 --- a/pkg/server/api/console/v1/base_resolvers.go +++ b/pkg/server/api/console/v1/base_resolvers.go @@ -17,6 +17,7 @@ import ( "go.probo.inc/probo/pkg/complianceportal/management" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/itam" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/console/v1/schema" @@ -394,6 +395,16 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error return types.NewAgentRun(run), nil } + case coredata.DeviceEntityType: + action = itam.ActionDeviceGet + loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) { + device, err := r.itam.GetDevice(ctx, scope, id) + if err != nil { + return nil, err + } + + return types.NewDevice(device), nil + } case coredata.AccessReviewCampaignEntityType: action = accessreview.ActionCampaignGet loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) { diff --git a/pkg/server/api/console/v1/device_resolvers.go b/pkg/server/api/console/v1/device_resolvers.go new file mode 100644 index 000000000..6a8f1ff5d --- /dev/null +++ b/pkg/server/api/console/v1/device_resolvers.go @@ -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 } +) diff --git a/pkg/server/api/console/v1/enrollment_url.go b/pkg/server/api/console/v1/enrollment_url.go new file mode 100644 index 000000000..86383d7ea --- /dev/null +++ b/pkg/server/api/console/v1/enrollment_url.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/server/api/console/v1/enrollment_url_test.go b/pkg/server/api/console/v1/enrollment_url_test.go new file mode 100644 index 000000000..976367d3b --- /dev/null +++ b/pkg/server/api/console/v1/enrollment_url_test.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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")) + }) + } +} diff --git a/pkg/server/api/console/v1/graphql/device.graphql b/pkg/server/api/console/v1/graphql/device.graphql new file mode 100644 index 000000000..5f72b470d --- /dev/null +++ b/pkg/server/api/console/v1/graphql/device.graphql @@ -0,0 +1,165 @@ +# Copyright (c) 2026 Probo Inc . +# +# 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! +} diff --git a/pkg/server/api/console/v1/graphql/organization.graphql b/pkg/server/api/console/v1/graphql/organization.graphql index db81e6dec..b2e33799f 100644 --- a/pkg/server/api/console/v1/graphql/organization.graphql +++ b/pkg/server/api/console/v1/graphql/organization.graphql @@ -342,6 +342,14 @@ type Organization implements Node { thirdPartiesDocument: Document @goField(forceResolver: true) + devices( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: DeviceOrder + ): DeviceConnection! @goField(forceResolver: true) + webhookSubscriptions( first: Int after: CursorKey diff --git a/pkg/server/api/console/v1/graphql/viewer.graphql b/pkg/server/api/console/v1/graphql/viewer.graphql index f77412224..a2cace8fd 100644 --- a/pkg/server/api/console/v1/graphql/viewer.graphql +++ b/pkg/server/api/console/v1/graphql/viewer.graphql @@ -22,4 +22,13 @@ type Viewer { ): EmployeeDocumentConnection! @goField(forceResolver: true) approvableDocument(id: ID!): EmployeeDocument @goField(forceResolver: true) + + enrolledDevices( + organizationId: ID! + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: DeviceOrder + ): DeviceConnection! @goField(forceResolver: true) } diff --git a/pkg/server/api/console/v1/graphql_handler.go b/pkg/server/api/console/v1/graphql_handler.go index f3d240d5d..b98f6be9d 100644 --- a/pkg/server/api/console/v1/graphql_handler.go +++ b/pkg/server/api/console/v1/graphql_handler.go @@ -35,6 +35,7 @@ import ( "go.probo.inc/probo/pkg/esign" "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/itam" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/resourcealias" @@ -67,6 +68,7 @@ func NewGraphQLHandler( fileManagerSvc *filemanager.Service, baseURL *baseurl.BaseURL, limits gqlutils.Limits, + itamSvc *itam.Service, ) http.Handler { config := schema.Config{ Resolvers: &Resolver{ @@ -90,6 +92,7 @@ func NewGraphQLHandler( tokenSecret: tokenSecret, fileManager: fileManagerSvc, baseURL: baseURL, + itam: itamSvc, logger: logger, }, } diff --git a/pkg/server/api/console/v1/organization_resolvers.go b/pkg/server/api/console/v1/organization_resolvers.go index e2151a297..5273679b2 100644 --- a/pkg/server/api/console/v1/organization_resolvers.go +++ b/pkg/server/api/console/v1/organization_resolvers.go @@ -17,6 +17,7 @@ import ( "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/itam" "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/server/api/console/v1/schema" @@ -1307,6 +1308,35 @@ func (r *organizationResolver) ThirdPartiesDocument(ctx context.Context, obj *ty return types.NewDocument(document), nil } +// Devices is the resolver for the devices field. +func (r *organizationResolver) Devices(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DeviceOrderBy) (*types.DeviceConnection, error) { + scope, err := r.authorize(ctx, obj.ID, itam.ActionDeviceList) + if err != nil { + return nil, err + } + + pageOrderBy := page.OrderBy[coredata.DeviceOrderField]{ + Field: coredata.DeviceOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.DeviceOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + devicesPage, err := r.itam.ListForOrganizationID(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list organization devices", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewDeviceConnection(devicesPage, r, obj.ID), nil +} + // WebhookSubscriptions is the resolver for the webhookSubscriptions field. func (r *organizationResolver) WebhookSubscriptions(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.WebhookSubscriptionOrderBy) (*types.WebhookSubscriptionConnection, error) { scope, err := r.authorize(ctx, obj.ID, probo.ActionWebhookSubscriptionList) diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index e1c755dbe..b79d34b88 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -45,6 +45,7 @@ import ( "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/itam" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/resourcealias" @@ -77,6 +78,7 @@ type ( providerRegistry *provider.Registry riskManagement *riskmanagement.Service thirdParty *thirdparty.Service + itam *itam.Service logger *log.Logger fileManager *filemanager.Service baseURL *baseurl.BaseURL @@ -107,6 +109,7 @@ func NewMux( thirdPartySvc *thirdparty.Service, riskManagementSvc *riskmanagement.Service, graphqlLimits gqlutils.Limits, + itamSvc *itam.Service, ) *chi.Mux { r := chi.NewMux() @@ -133,6 +136,7 @@ func NewMux( fileManagerSvc, baseURL, graphqlLimits, + itamSvc, ) r.Group(func(r chi.Router) { @@ -506,5 +510,6 @@ func isValidPagerDutySubdomain(s string) bool { func (r *Resolver) Permission(ctx context.Context, obj types.Node, action string) (bool, error) { _, err := r.authorize(ctx, obj.GetID(), action, authz.WithDryRun()) + return err == nil, nil } diff --git a/pkg/server/api/console/v1/types/device.go b/pkg/server/api/console/v1/types/device.go new file mode 100644 index 000000000..3de4381d9 --- /dev/null +++ b/pkg/server/api/console/v1/types/device.go @@ -0,0 +1,120 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// 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 +} diff --git a/pkg/server/api/console/v1/viewer_resolvers.go b/pkg/server/api/console/v1/viewer_resolvers.go index 0905c54e3..5716e8299 100644 --- a/pkg/server/api/console/v1/viewer_resolvers.go +++ b/pkg/server/api/console/v1/viewer_resolvers.go @@ -12,6 +12,8 @@ import ( "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/itam" "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/server/api/authn" @@ -180,6 +182,54 @@ func (r *viewerResolver) ApprovableDocument(ctx context.Context, obj *types.View }, nil } +// EnrolledDevices is the resolver for the enrolledDevices field. +func (r *viewerResolver) EnrolledDevices(ctx context.Context, obj *types.Viewer, organizationID gid.GID, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DeviceOrderBy) (*types.DeviceConnection, error) { + scope, err := r.authorize(ctx, organizationID, itam.ActionEmployeeDeviceList) + if err != nil { + return nil, err + } + + pageOrderBy := page.OrderBy[coredata.DeviceOrderField]{ + Field: coredata.DeviceOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.DeviceOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + identity := authn.IdentityFromContext(ctx) + + profile, err := r.iam.OrganizationService.GetProfileForIdentityAndOrganization( + ctx, + identity.ID, + organizationID, + ) + if err != nil { + if _, ok := errors.AsType[*iam.ErrProfileNotFound](err); ok { + return nil, gqlutils.NotFound(ctx, err) + } + + r.logger.ErrorCtx(ctx, "cannot get enrolled devices owner profile", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + devicesPage, err := r.itam.ListForOrganizationIDAndOwnerID( + ctx, scope, organizationID, profile.ID, cursor, + ) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list enrolled devices", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewOwnedDeviceConnection(devicesPage, r, organizationID, profile.ID), nil +} + // Viewer returns schema.ViewerResolver implementation. func (r *Resolver) Viewer() schema.ViewerResolver { return &viewerResolver{r} } diff --git a/pkg/server/jsonx/errors.go b/pkg/server/jsonx/errors.go index c8fb7cd4d..d9e637482 100644 --- a/pkg/server/jsonx/errors.go +++ b/pkg/server/jsonx/errors.go @@ -42,3 +42,7 @@ func RenderNotFound(w http.ResponseWriter, err error) { func RenderBadRequest(w http.ResponseWriter, err error) { httpserver.RenderError(w, http.StatusBadRequest, err) } + +func RenderUnauthorized(w http.ResponseWriter, err error) { + httpserver.RenderError(w, http.StatusUnauthorized, err) +} diff --git a/pkg/server/server.go b/pkg/server/server.go index aa855c169..fc5d08156 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -39,6 +39,7 @@ import ( "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/geoloc" "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/itam" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/resourcealias" @@ -74,6 +75,7 @@ type Config struct { Geoloc *geoloc.Service ThirdParty *thirdparty.Service RiskManagement *riskmanagement.Service + ITAM *itam.Service Cookie securecookie.Config TokenSecret string ConnectorRegistry *connector.ConnectorRegistry @@ -116,6 +118,7 @@ func NewServer(cfg Config) (*Server, error) { Geoloc: cfg.Geoloc, ThirdParty: cfg.ThirdParty, RiskManagement: cfg.RiskManagement, + ITAM: cfg.ITAM, Cookie: cfg.Cookie, TokenSecret: cfg.TokenSecret, ConnectorRegistry: cfg.ConnectorRegistry,