From 1f7945338657c8a765d2058a3b1d611e07274104 Mon Sep 17 00:00:00 2001 From: Ludovic Vielle Date: Tue, 14 Jul 2026 20:38:38 +0200 Subject: [PATCH] Add device data model and ITAM service Introduce device, posture, and enrollment-token entities with ITAM service policies for agent-managed fleet inventory. Signed-off-by: Ludovic Vielle --- contrib/claude/coredata.md | 11 + contrib/claude/ownership.md | 158 +++ pkg/coredata/device.go | 896 ++++++++++++++++++ pkg/coredata/device_enrollment_token.go | 169 ++++ pkg/coredata/device_order_field.go | 100 ++ pkg/coredata/device_order_field_test.go | 53 ++ pkg/coredata/device_platform.go | 82 ++ pkg/coredata/device_posture.go | 248 +++++ pkg/coredata/device_posture_check_key.go | 100 ++ .../device_posture_observed_at_test.go | 80 ++ pkg/coredata/device_posture_order_field.go | 84 ++ pkg/coredata/device_posture_status.go | 82 ++ pkg/coredata/device_posture_test.go | 210 ++++ pkg/coredata/device_state.go | 79 ++ pkg/coredata/entity_type_reg.go | 9 + pkg/coredata/migrations/20260710T120000Z.sql | 106 +++ pkg/coredata/migrations/20260711T094136Z.sql | 41 + pkg/itam/actions.go | 37 + pkg/itam/gc.go | 112 +++ pkg/itam/policies.go | 78 ++ pkg/itam/service.go | 856 +++++++++++++++++ 21 files changed, 3591 insertions(+) create mode 100644 contrib/claude/ownership.md create mode 100644 pkg/coredata/device.go create mode 100644 pkg/coredata/device_enrollment_token.go create mode 100644 pkg/coredata/device_order_field.go create mode 100644 pkg/coredata/device_order_field_test.go create mode 100644 pkg/coredata/device_platform.go create mode 100644 pkg/coredata/device_posture.go create mode 100644 pkg/coredata/device_posture_check_key.go create mode 100644 pkg/coredata/device_posture_observed_at_test.go create mode 100644 pkg/coredata/device_posture_order_field.go create mode 100644 pkg/coredata/device_posture_status.go create mode 100644 pkg/coredata/device_posture_test.go create mode 100644 pkg/coredata/device_state.go create mode 100644 pkg/coredata/migrations/20260710T120000Z.sql create mode 100644 pkg/coredata/migrations/20260711T094136Z.sql create mode 100644 pkg/itam/actions.go create mode 100644 pkg/itam/gc.go create mode 100644 pkg/itam/policies.go create mode 100644 pkg/itam/service.go diff --git a/contrib/claude/coredata.md b/contrib/claude/coredata.md index 6f1a34609..a883ec741 100644 --- a/contrib/claude/coredata.md +++ b/contrib/claude/coredata.md @@ -509,6 +509,17 @@ json.Unmarshal(plaintext, &c.Connection) - **Return plaintext tokens once.** For SHA-256-hashed tokens, return the raw token to the caller at creation time only. After that, the application only ever sees the hash. - **Migration columns.** When adding a new sensitive column, always use `BYTEA`. Never add `DEFAULT` on sensitive columns. +## Resource ownership + +Org-scoped owners are stored as `owner_profile_id` → `iam_membership_profiles`, +not identity GIDs. GraphQL uses `ownerId`; resolvers load `owner: Profile` via +dataloader. Employee self-service resolves identity → profile at the API +boundary; IAM may bridge profile storage back to identity in +`AuthorizationAttributes`. + +See [`ownership.md`](ownership.md) for the full pattern (provisional — pending +team review). + ## New entity checklist 1. **Entity file** (`entity.go`) — struct with `db` tags, slice type alias, `LoadByID`, `Insert`, `Update`, `Delete`, `CursorKey`, `AuthorizationAttributes` diff --git a/contrib/claude/ownership.md b/contrib/claude/ownership.md new file mode 100644 index 000000000..fc04c547f --- /dev/null +++ b/contrib/claude/ownership.md @@ -0,0 +1,158 @@ +# Resource ownership + +**Status: provisional.** This documents the pattern the codebase follows today. The +team has not formally ratified profile-based ownership over identity-based +ownership; treat this guide as the default for new work until that decision is +revisited. + +## Model + +Organization-scoped resources name a **membership profile** as owner — the +person's membership in that organization — not the global **identity**. + +| Concept | GID entity type | Scope | +| ------- | --------------- | ----- | +| Identity | `Identity` | Global person (login, sessions) | +| Membership profile | `MembershipProfile` | Person within one organization | + +Ownership answers: *who in this org is responsible for this resource?* That is +always a membership profile, even when the UX speaks in terms of "people". + +## Layer conventions + +Use the same shape across DB, services, GraphQL, and console pickers. + +| Layer | Convention | +| ----- | ------------ | +| Database column | `owner_profile_id TEXT REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE RESTRICT` | +| Go struct field | `OwnerID` with `` `db:"owner_profile_id"` `` (name stays `OwnerID`; tag names the column) | +| GraphQL input | `ownerId: ID` on create/update mutations | +| GraphQL output | `owner: Profile` resolved via profile dataloader | +| Validation | `validator.GID(coredata.MembershipProfileEntityType)` | +| Service create/update | `profile.LoadByID` inside the transaction; verify profile belongs to the resource's `organization_id` when not already enforced by scope. Self-service enroll accepts `IdentityID` and uses `LoadByIdentityIDAndOrganizationID`; admin create uses explicit `OwnerID` only | +| Console people picker | [`PeopleSelectField`](../../apps/console/src/components/form/PeopleSelectField.tsx) — value is `Profile.id` | + +### Resources that follow this pattern + +- Assets, data (datum), risks, obligations, findings +- Third parties (`business_owner_profile_id`, `security_owner_profile_id`) +- Devices (ITAM) — aligned with compliance resources as of the devices table + introduction + +## GraphQL resolver pattern + +Store only the profile GID on the GraphQL type; resolve `owner` in a field +resolver. Authorize the **profile** GID only — do not re-authorize the parent +resource for the `owner` field (parent access is already established). + +Default for new work is a **nullable** owner (`OwnerID *gid.GID`, GraphQL +`owner: Profile`). Set the embedded profile only when present so the resolver +nil guard is live (device, risk, finding, third-party owners): + +```go +// types — set only when present +if resource.OwnerID != nil { + obj.Owner = &Profile{ID: *resource.OwnerID} +} + +// resolver — authorize profile, then load +if obj.Owner == nil { + return nil, nil +} + +if _, err := r.authorize(ctx, obj.Owner.ID, iam.ActionMembershipProfileGet); err != nil { + return nil, err +} + +owner, err := loaders.Profile.Load(ctx, obj.Owner.ID) +``` + +For a **required** owner (`OwnerID gid.GID`, GraphQL `owner: Profile!` — asset, +datum, obligation), always embed `Owner: &Profile{ID: …}` in the constructor and +omit the `obj.Owner == nil` guard. + +Do not reverse-lookup identity → profile at read time when the profile id is +already stored. + +## Employee and self-service flows + +Authenticated users are **identities**. Device create vs enroll: + +- **`enrollDevice`** (employee self-service): resolver passes `identity.ID`; + service resolves the membership profile with + `LoadByIdentityIDAndOrganizationID` and always sets that profile as owner. +- **`createDevice`** (admin): resolver passes only the optional `ownerId` from + the picker. No identity default — omitted/`null` means unowned. + +List/count filters that key on `owner_profile_id` (e.g. `viewer.enrolledDevices`) +may still resolve identity → profile at the resolver until a list-by-identity +helper exists. + +## IAM when policies compare identity + +Some policies match `principal.id` (identity) to a resource attribute, e.g. +ITAM employee access to own devices: + +```go +ownerCondition = policy.Equals("principal.id", "resource.owner_id") +``` + +Storage is profile-based, but the policy still compares identities. Bridge in +`AuthorizationAttributes` with two entity queries — no cross-entity JOINs (see +[`coredata.md`](coredata.md#no-cross-entity-joins)): + +```go +// Step 1 — devices table only +SELECT id, organization_id, owner_profile_id +FROM devices +WHERE id = ANY(@resource_ids::text[]) + +// Step 2 — profiles table only (e.g. MembershipProfile.AuthorizationAttributes) +SELECT id, identity_id +FROM iam_membership_profiles +WHERE id = ANY(@profile_ids::text[]) + +// Step 3 — map in Go: attrs["owner_id"] = identityByProfileID[ownerProfileID] +``` + +Policy code stays unchanged; only the attributer translates profile storage → +identity comparison. + +## Checklist for a new owned resource + +1. Migration: `owner_profile_id` FK to `iam_membership_profiles`. +2. Entity struct: `OwnerID` with `owner_profile_id` db tag. +3. GraphQL: `ownerId` input, `owner: Profile` output with `forceResolver`. +4. Service: validate `MembershipProfileEntityType`; load profile in tx. For + self-service enroll, accept `IdentityID` and resolve with + `LoadByIdentityIDAndOrganizationID`. Admin create uses explicit `OwnerID` + only (nil means unowned). +5. Types: for nullable owners, set `Owner` only when `OwnerID != nil`; for + required owners (`Profile!`), always embed `Owner: &Profile{ID: …}`. +6. Resolver `owner` field: nil-guard when nullable; `authorize` with + `ActionMembershipProfileGet` on the profile GID, then `loaders.Profile.Load` + (no parent-resource authorize). +7. If employee self-service lists by caller: resolve identity → profile (resolver + or service) and query by `owner_profile_id`. +8. If IAM compares `principal.id` to owner: bridge identity in + `AuthorizationAttributes` (see above). + +## Open question (team review) + +An alternative is storing **identity** GIDs for person-centric resources (e.g. +devices tied to a person across org context) and accepting profile ids at the +API boundary via normalization. Compliance resources migrated from `peoples` to +membership profiles in [`20260203T132700Z.sql`](../../pkg/coredata/migrations/20260203T132700Z.sql); +devices were added on the profile model to stay consistent. + +When adding ownership to a new resource, default to **profile** unless there is +a documented reason to anchor on identity. Raise identity-based ownership in +design review if the resource lifecycle is person-global rather than +org-scoped. + +## Related + +- [`coredata.md`](coredata.md) — entity structs, migrations, `AuthorizationAttributes` +- [`authorization.md`](authorization.md) — policy conditions and attributers +- [`validation.md`](validation.md) — `validator.GID` entity-type checks +- [`graphql.md`](graphql.md) — `@goField(forceResolver: true)` for `owner` diff --git a/pkg/coredata/device.go b/pkg/coredata/device.go new file mode 100644 index 000000000..4768ef42c --- /dev/null +++ b/pkg/coredata/device.go @@ -0,0 +1,896 @@ +// 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 coredata + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" + "go.probo.inc/probo/pkg/page" +) + +var emptyJSONObject = json.RawMessage(`{}`) + +type ( + Device struct { + ID gid.GID `db:"id"` + TenantID gid.TenantID `db:"tenant_id"` + OrganizationID gid.GID `db:"organization_id"` + State DeviceState `db:"state"` + HardwareUUID *string `db:"hardware_uuid"` + SerialNumber *string `db:"serial_number"` + Hostname *string `db:"hostname"` + Platform *DevicePlatform `db:"platform"` + OSVersion *string `db:"os_version"` + AgentVersion *string `db:"agent_version"` + APIKeyHash []byte `db:"api_key_hash"` + OwnerID *gid.GID `db:"owner_profile_id"` + Labels json.RawMessage `db:"labels"` + EnrolledAt *time.Time `db:"enrolled_at"` + LastSeenAt *time.Time `db:"last_seen_at"` + RevokedAt *time.Time `db:"revoked_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + Devices []*Device +) + +func (d *Device) CursorKey(orderBy DeviceOrderField) page.CursorKey { + switch orderBy { + case DeviceOrderFieldCreatedAt: + return page.NewCursorKey(d.ID, d.CreatedAt) + case DeviceOrderFieldUpdatedAt: + return page.NewCursorKey(d.ID, d.UpdatedAt) + case DeviceOrderFieldHostname: + hostname := "" + if d.Hostname != nil { + hostname = *d.Hostname + } + + return page.NewCursorKey(d.ID, hostname) + case DeviceOrderFieldLastSeenAt: + lastSeen := time.Time{} + if d.LastSeenAt != nil { + lastSeen = *d.LastSeenAt + } + + return page.NewCursorKey(d.ID, lastSeen) + } + + panic(fmt.Sprintf("unsupported order by: %s", orderBy)) +} + +func (d *Device) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := ` +SELECT + id, + organization_id, + owner_profile_id +FROM + devices +WHERE + id = ANY(@resource_ids::text[]) +` + + rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{"resource_ids": resourceIDs}) + if err != nil { + return nil, fmt.Errorf("cannot query device authorization attributes: %w", err) + } + defer rows.Close() + + attrsByID := make(policy.AttributesByID, len(resourceIDs)) + ownerProfileByDeviceID := make(map[gid.GID]*gid.GID, len(resourceIDs)) + profileIDSet := make(map[gid.GID]struct{}) + + for rows.Next() { + var ( + id, organizationID gid.GID + ownerProfileID *gid.GID + ) + if err := rows.Scan(&id, &organizationID, &ownerProfileID); err != nil { + return nil, fmt.Errorf("cannot scan device authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + ownerProfileByDeviceID[id] = ownerProfileID + + if ownerProfileID != nil { + profileIDSet[*ownerProfileID] = struct{}{} + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate device authorization attributes: %w", err) + } + + if len(profileIDSet) > 0 { + profileIDs := make([]gid.GID, 0, len(profileIDSet)) + for profileID := range profileIDSet { + profileIDs = append(profileIDs, profileID) + } + + var profile MembershipProfile + + profileAttrsByID, err := profile.AuthorizationAttributes(ctx, conn, profileIDs) + if err != nil { + return nil, fmt.Errorf("cannot load profile authorization attributes: %w", err) + } + + identityByProfileID := make(map[gid.GID]string, len(profileAttrsByID)) + for profileID, profileAttrs := range profileAttrsByID { + if identityID, ok := profileAttrs["identity_id"]; ok { + identityByProfileID[profileID] = identityID + } + } + + for deviceID, ownerProfileID := range ownerProfileByDeviceID { + if ownerProfileID == nil { + continue + } + + if ownerIdentityID, ok := identityByProfileID[*ownerProfileID]; ok { + attrsByID[deviceID]["owner_id"] = ownerIdentityID + } + } + } + + return attrsByID, nil +} + +func (d *Device) LoadByID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + deviceID gid.GID, +) error { + q := ` +SELECT + id, + tenant_id, + organization_id, + state, + hardware_uuid, + serial_number, + hostname, + platform, + os_version, + agent_version, + api_key_hash, + owner_profile_id, + labels, + enrolled_at, + last_seen_at, + revoked_at, + created_at, + updated_at +FROM + devices +WHERE + %s + AND id = @device_id +LIMIT 1; +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"device_id": deviceID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query device: %w", err) + } + + device, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Device]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect device: %w", err) + } + + *d = device + + return nil +} + +func (d *Device) LoadByIDForUpdate( + ctx context.Context, + conn pg.Tx, + scope Scoper, + deviceID gid.GID, +) error { + q := ` +SELECT + id, + tenant_id, + organization_id, + state, + hardware_uuid, + serial_number, + hostname, + platform, + os_version, + agent_version, + api_key_hash, + owner_profile_id, + labels, + enrolled_at, + last_seen_at, + revoked_at, + created_at, + updated_at +FROM + devices +WHERE + %s + AND id = @device_id +LIMIT 1 +FOR UPDATE; +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"device_id": deviceID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query device: %w", err) + } + + device, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Device]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect device: %w", err) + } + + *d = device + + return nil +} + +// LoadByAPIKeyHash loads a non-revoked device by its API key hash without +// requiring a tenant scope (the device key itself is the credential). +func (d *Device) LoadByAPIKeyHash( + ctx context.Context, + conn pg.Querier, + apiKeyHash []byte, +) error { + q := ` +SELECT + id, + tenant_id, + organization_id, + state, + hardware_uuid, + serial_number, + hostname, + platform, + os_version, + agent_version, + api_key_hash, + owner_profile_id, + labels, + enrolled_at, + last_seen_at, + revoked_at, + created_at, + updated_at +FROM + devices +WHERE + api_key_hash = @api_key_hash + AND state != @revoked_state +LIMIT 1; +` + + args := pgx.StrictNamedArgs{ + "api_key_hash": apiKeyHash, + "revoked_state": DeviceStateRevoked, + } + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query device by api key hash: %w", err) + } + + device, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Device]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect device: %w", err) + } + + *d = device + + return nil +} + +func (d *Device) LoadByHardwareUUID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + organizationID gid.GID, + hardwareUUID string, +) error { + q := ` +SELECT + id, + tenant_id, + organization_id, + state, + hardware_uuid, + serial_number, + hostname, + platform, + os_version, + agent_version, + api_key_hash, + owner_profile_id, + labels, + enrolled_at, + last_seen_at, + revoked_at, + created_at, + updated_at +FROM + devices +WHERE + %s + AND organization_id = @organization_id + AND hardware_uuid = @hardware_uuid +LIMIT 1; +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "organization_id": organizationID, + "hardware_uuid": hardwareUUID, + } + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query device by hardware uuid: %w", err) + } + + device, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Device]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect device: %w", err) + } + + *d = device + + return nil +} + +func (d Device) Insert( + ctx context.Context, + conn pg.Tx, + scope Scoper, +) error { + labels := d.Labels + if len(labels) == 0 { + labels = emptyJSONObject + } + + q := ` +INSERT INTO devices ( + id, + tenant_id, + organization_id, + state, + hardware_uuid, + serial_number, + hostname, + platform, + os_version, + agent_version, + api_key_hash, + owner_profile_id, + labels, + enrolled_at, + last_seen_at, + revoked_at, + created_at, + updated_at +) VALUES ( + @device_id, + @tenant_id, + @organization_id, + @state, + @hardware_uuid, + @serial_number, + @hostname, + @platform, + @os_version, + @agent_version, + @api_key_hash, + @owner_profile_id, + @labels, + @enrolled_at, + @last_seen_at, + @revoked_at, + @created_at, + @updated_at +) +` + args := pgx.StrictNamedArgs{ + "device_id": d.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": d.OrganizationID, + "state": d.State, + "hardware_uuid": d.HardwareUUID, + "serial_number": d.SerialNumber, + "hostname": d.Hostname, + "platform": d.Platform, + "os_version": d.OSVersion, + "agent_version": d.AgentVersion, + "api_key_hash": d.APIKeyHash, + "owner_profile_id": d.OwnerID, + "labels": labels, + "enrolled_at": d.EnrolledAt, + "last_seen_at": d.LastSeenAt, + "revoked_at": d.RevokedAt, + "created_at": d.CreatedAt, + "updated_at": d.UpdatedAt, + } + + _, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot insert device: %w", err) + } + + return nil +} + +func (d *Device) SetAPIKeyHash( + ctx context.Context, + conn pg.Tx, + scope Scoper, + apiKeyHash []byte, +) error { + now := time.Now() + + q := fmt.Sprintf(` +UPDATE devices +SET + api_key_hash = @api_key_hash, + updated_at = @updated_at +WHERE %s + AND id = @device_id + AND state = @pending_state + AND api_key_hash IS NULL +`, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "device_id": d.ID, + "api_key_hash": apiKeyHash, + "updated_at": now, + "pending_state": DeviceStatePending, + } + maps.Copy(args, scope.SQLArguments()) + + result, err := conn.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" && pgErr.ConstraintName == "devices_api_key_hash_idx" { + return ErrResourceAlreadyExists + } + + return fmt.Errorf("cannot set device api key hash: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + d.APIKeyHash = apiKeyHash + d.UpdatedAt = now + + return nil +} + +func (d *Device) Activate( + ctx context.Context, + conn pg.Tx, + scope Scoper, +) error { + now := time.Now() + + q := fmt.Sprintf(` +UPDATE devices +SET + hardware_uuid = @hardware_uuid, + serial_number = @serial_number, + hostname = @hostname, + platform = @platform, + os_version = @os_version, + agent_version = @agent_version, + state = @active_state, + enrolled_at = @now, + last_seen_at = @now, + updated_at = @now +WHERE %s + AND id = @device_id + AND state = @pending_state +`, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "device_id": d.ID, + "hardware_uuid": d.HardwareUUID, + "serial_number": d.SerialNumber, + "hostname": d.Hostname, + "platform": d.Platform, + "os_version": d.OSVersion, + "agent_version": d.AgentVersion, + "active_state": DeviceStateActive, + "pending_state": DeviceStatePending, + "now": now, + } + maps.Copy(args, scope.SQLArguments()) + + result, err := conn.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" && pgErr.ConstraintName == "devices_org_hardware_uuid_idx" { + return ErrResourceAlreadyExists + } + + return fmt.Errorf("cannot activate device: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + d.State = DeviceStateActive + d.EnrolledAt = &now + d.LastSeenAt = &now + d.UpdatedAt = now + + return nil +} + +func (d *Device) UpdateHeartbeat( + ctx context.Context, + conn pg.Tx, + scope Scoper, +) error { + now := time.Now() + + q := fmt.Sprintf(` +UPDATE devices +SET + hostname = @hostname, + os_version = @os_version, + agent_version = @agent_version, + last_seen_at = @last_seen_at, + updated_at = @updated_at +WHERE %s + AND id = @device_id + AND state = @active_state +`, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "device_id": d.ID, + "hostname": d.Hostname, + "os_version": d.OSVersion, + "agent_version": d.AgentVersion, + "last_seen_at": now, + "updated_at": now, + "active_state": DeviceStateActive, + } + maps.Copy(args, scope.SQLArguments()) + + result, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot update device heartbeat: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + d.LastSeenAt = &now + d.UpdatedAt = now + + return nil +} + +func (d *Device) Revoke( + ctx context.Context, + conn pg.Tx, + scope Scoper, +) error { + now := time.Now() + + q := fmt.Sprintf(` +UPDATE devices +SET + state = @revoked_state, + revoked_at = COALESCE(revoked_at, @now), + updated_at = @now +WHERE %s + AND id = @device_id +`, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "device_id": d.ID, + "revoked_state": DeviceStateRevoked, + "now": now, + } + maps.Copy(args, scope.SQLArguments()) + + result, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot revoke device: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + d.State = DeviceStateRevoked + if d.RevokedAt == nil { + d.RevokedAt = &now + } + + d.UpdatedAt = now + + return nil +} + +func (d *Device) AssignOwner( + ctx context.Context, + conn pg.Tx, + scope Scoper, + ownerProfileID *gid.GID, +) error { + now := time.Now() + + q := fmt.Sprintf(` +UPDATE devices +SET + owner_profile_id = @owner_profile_id, + updated_at = @now +WHERE %s + AND id = @device_id +`, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "device_id": d.ID, + "owner_profile_id": ownerProfileID, + "now": now, + } + maps.Copy(args, scope.SQLArguments()) + + result, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot assign device user: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + d.OwnerID = ownerProfileID + d.UpdatedAt = now + + return nil +} + +func (ds *Devices) LoadByOrganizationID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + organizationID gid.GID, + cursor *page.Cursor[DeviceOrderField], +) error { + q := ` +SELECT + id, + tenant_id, + organization_id, + state, + hardware_uuid, + serial_number, + hostname, + platform, + os_version, + agent_version, + api_key_hash, + owner_profile_id, + labels, + enrolled_at, + last_seen_at, + revoked_at, + created_at, + updated_at +FROM + devices +WHERE + %s + AND organization_id = @organization_id + AND %s +` + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + + args := pgx.StrictNamedArgs{"organization_id": organizationID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query devices: %w", err) + } + + devices, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Device]) + if err != nil { + return fmt.Errorf("cannot collect devices: %w", err) + } + + *ds = devices + + return nil +} + +func (ds *Devices) LoadByOrganizationIDAndOwnerID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + organizationID gid.GID, + ownerID gid.GID, + cursor *page.Cursor[DeviceOrderField], +) error { + q := ` +SELECT + id, + tenant_id, + organization_id, + state, + hardware_uuid, + serial_number, + hostname, + platform, + os_version, + agent_version, + api_key_hash, + owner_profile_id, + labels, + enrolled_at, + last_seen_at, + revoked_at, + created_at, + updated_at +FROM + devices +WHERE + %s + AND organization_id = @organization_id + AND owner_profile_id = @owner_profile_id + AND state = @active_state + AND %s +` + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "organization_id": organizationID, + "owner_profile_id": ownerID, + "active_state": DeviceStateActive, + } + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query devices by owner: %w", err) + } + + devices, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Device]) + if err != nil { + return fmt.Errorf("cannot collect devices by owner: %w", err) + } + + *ds = devices + + return nil +} + +func (ds *Devices) CountByOrganizationID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + organizationID gid.GID, +) (int, error) { + q := fmt.Sprintf(` +SELECT COUNT(id) FROM devices +WHERE %s AND organization_id = @organization_id +`, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"organization_id": organizationID} + maps.Copy(args, scope.SQLArguments()) + + var count int + if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil { + return 0, fmt.Errorf("cannot count devices: %w", err) + } + + return count, nil +} + +func (ds *Devices) CountByOrganizationIDAndOwnerID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + organizationID gid.GID, + ownerID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + devices +WHERE + %s + AND organization_id = @organization_id + AND owner_profile_id = @owner_profile_id + AND state = @active_state +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "organization_id": organizationID, + "owner_profile_id": ownerID, + "active_state": DeviceStateActive, + } + maps.Copy(args, scope.SQLArguments()) + + var count int + if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil { + return 0, fmt.Errorf("cannot count devices by owner: %w", err) + } + + return count, nil +} diff --git a/pkg/coredata/device_enrollment_token.go b/pkg/coredata/device_enrollment_token.go new file mode 100644 index 000000000..cb275cef2 --- /dev/null +++ b/pkg/coredata/device_enrollment_token.go @@ -0,0 +1,169 @@ +// 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 coredata + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" +) + +type DeviceEnrollmentToken struct { + ID gid.GID `db:"id"` + TenantID gid.TenantID `db:"tenant_id"` + DeviceID gid.GID `db:"device_id"` + HashedValue []byte `db:"hashed_value"` + ExpiresAt time.Time `db:"expires_at"` + CreatedAt time.Time `db:"created_at"` +} + +func (t *DeviceEnrollmentToken) Insert( + ctx context.Context, + conn pg.Tx, + scope Scoper, +) error { + q := ` +INSERT INTO device_enrollment_tokens ( + id, + tenant_id, + device_id, + hashed_value, + expires_at, + created_at +) VALUES ( + @id, + @tenant_id, + @device_id, + @hashed_value, + @expires_at, + @created_at +) +` + + args := pgx.StrictNamedArgs{ + "id": t.ID, + "tenant_id": scope.GetTenantID(), + "device_id": t.DeviceID, + "hashed_value": t.HashedValue, + "expires_at": t.ExpiresAt, + "created_at": t.CreatedAt, + } + + _, err := conn.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok { + if pgErr.Code == "23505" && pgErr.ConstraintName == "device_enrollment_tokens_hashed_value_unique" { + return ErrResourceAlreadyExists + } + } + + return fmt.Errorf("cannot insert device_enrollment_token: %w", err) + } + + return nil +} + +func (t *DeviceEnrollmentToken) LoadByHashedValueForUpdate( + ctx context.Context, + conn pg.Tx, + hashedValue []byte, +) error { + q := ` +SELECT + id, + tenant_id, + device_id, + hashed_value, + expires_at, + created_at +FROM + device_enrollment_tokens +WHERE + hashed_value = @hashed_value +LIMIT 1 +FOR UPDATE; +` + + args := pgx.StrictNamedArgs{"hashed_value": hashedValue} + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query device_enrollment_tokens: %w", err) + } + + token, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DeviceEnrollmentToken]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect device_enrollment_token: %w", err) + } + + *t = token + + return nil +} + +func (t *DeviceEnrollmentToken) DeleteExpired( + ctx context.Context, + conn pg.Tx, + now time.Time, +) (int64, error) { + q := ` +DELETE FROM device_enrollment_tokens +WHERE + expires_at < @now +` + + result, err := conn.Exec(ctx, q, pgx.StrictNamedArgs{"now": now}) + if err != nil { + return 0, fmt.Errorf("cannot delete expired device_enrollment_tokens: %w", err) + } + + return result.RowsAffected(), nil +} + +func (t *DeviceEnrollmentToken) Delete( + ctx context.Context, + conn pg.Tx, +) error { + q := ` +DELETE FROM device_enrollment_tokens +WHERE + id = @id +` + + args := pgx.StrictNamedArgs{"id": t.ID} + + _, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot delete device_enrollment_token: %w", err) + } + + return nil +} diff --git a/pkg/coredata/device_order_field.go b/pkg/coredata/device_order_field.go new file mode 100644 index 000000000..d07b7dd17 --- /dev/null +++ b/pkg/coredata/device_order_field.go @@ -0,0 +1,100 @@ +// 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 coredata + +import ( + "encoding" + "fmt" + + "go.probo.inc/probo/pkg/page" +) + +type DeviceOrderField string + +const ( + DeviceOrderFieldCreatedAt DeviceOrderField = "CREATED_AT" + DeviceOrderFieldUpdatedAt DeviceOrderField = "UPDATED_AT" + DeviceOrderFieldHostname DeviceOrderField = "HOSTNAME" + DeviceOrderFieldLastSeenAt DeviceOrderField = "LAST_SEEN_AT" +) + +var ( + _ page.OrderField = DeviceOrderField("") + _ fmt.Stringer = DeviceOrderField("") + _ encoding.TextMarshaler = DeviceOrderField("") + _ encoding.TextUnmarshaler = (*DeviceOrderField)(nil) +) + +func DeviceOrderFields() []DeviceOrderField { + return []DeviceOrderField{ + DeviceOrderFieldCreatedAt, + DeviceOrderFieldUpdatedAt, + DeviceOrderFieldHostname, + DeviceOrderFieldLastSeenAt, + } +} + +func (v DeviceOrderField) IsValid() bool { + switch v { + case + DeviceOrderFieldCreatedAt, + DeviceOrderFieldUpdatedAt, + DeviceOrderFieldHostname, + DeviceOrderFieldLastSeenAt: + return true + } + + return false +} + +func (v DeviceOrderField) String() string { + return string(v) +} + +func (v DeviceOrderField) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v *DeviceOrderField) UnmarshalText(text []byte) error { + val := DeviceOrderField(text) + if !val.IsValid() { + return fmt.Errorf("invalid DeviceOrderField value: %q", string(text)) + } + + *v = val + + return nil +} + +func (f DeviceOrderField) Column() string { + switch f { + case DeviceOrderFieldCreatedAt: + return "created_at" + case DeviceOrderFieldUpdatedAt: + return "updated_at" + case DeviceOrderFieldHostname: + return "COALESCE(hostname, '')" + case DeviceOrderFieldLastSeenAt: + return "COALESCE(last_seen_at, '0001-01-01T00:00:00Z'::timestamptz)" + } + + panic(fmt.Sprintf("unsupported order by: %s", f)) +} diff --git a/pkg/coredata/device_order_field_test.go b/pkg/coredata/device_order_field_test.go new file mode 100644 index 000000000..3ea2c47e1 --- /dev/null +++ b/pkg/coredata/device_order_field_test.go @@ -0,0 +1,53 @@ +// 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 coredata_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.probo.inc/probo/pkg/coredata" +) + +func TestDeviceOrderField_Column(t *testing.T) { + t.Parallel() + + tests := []struct { + field coredata.DeviceOrderField + want string + }{ + {field: coredata.DeviceOrderFieldCreatedAt, want: "created_at"}, + {field: coredata.DeviceOrderFieldUpdatedAt, want: "updated_at"}, + {field: coredata.DeviceOrderFieldHostname, want: "COALESCE(hostname, '')"}, + { + field: coredata.DeviceOrderFieldLastSeenAt, + want: "COALESCE(last_seen_at, '0001-01-01T00:00:00Z'::timestamptz)", + }, + } + + for _, tt := range tests { + t.Run(string(tt.field), func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, tt.field.Column()) + }) + } +} diff --git a/pkg/coredata/device_platform.go b/pkg/coredata/device_platform.go new file mode 100644 index 000000000..c8c31f047 --- /dev/null +++ b/pkg/coredata/device_platform.go @@ -0,0 +1,82 @@ +// 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 coredata + +import ( + "encoding" + "fmt" +) + +type DevicePlatform string + +const ( + DevicePlatformDarwin DevicePlatform = "DARWIN" + DevicePlatformLinux DevicePlatform = "LINUX" + DevicePlatformFreeBSD DevicePlatform = "FREEBSD" + DevicePlatformWindows DevicePlatform = "WINDOWS" +) + +var ( + _ fmt.Stringer = DevicePlatform("") + _ encoding.TextMarshaler = DevicePlatform("") + _ encoding.TextUnmarshaler = (*DevicePlatform)(nil) +) + +func DevicePlatforms() []DevicePlatform { + return []DevicePlatform{ + DevicePlatformDarwin, + DevicePlatformLinux, + DevicePlatformFreeBSD, + DevicePlatformWindows, + } +} + +func (v DevicePlatform) IsValid() bool { + switch v { + case + DevicePlatformDarwin, + DevicePlatformLinux, + DevicePlatformFreeBSD, + DevicePlatformWindows: + return true + } + + return false +} + +func (v DevicePlatform) String() string { + return string(v) +} + +func (v DevicePlatform) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v *DevicePlatform) UnmarshalText(text []byte) error { + val := DevicePlatform(text) + if !val.IsValid() { + return fmt.Errorf("invalid DevicePlatform value: %q", string(text)) + } + + *v = val + + return nil +} diff --git a/pkg/coredata/device_posture.go b/pkg/coredata/device_posture.go new file mode 100644 index 000000000..0b92c0450 --- /dev/null +++ b/pkg/coredata/device_posture.go @@ -0,0 +1,248 @@ +// 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 coredata + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + DevicePosture struct { + ID gid.GID `db:"id"` + TenantID gid.TenantID `db:"tenant_id"` + OrganizationID gid.GID `db:"organization_id"` + DeviceID gid.GID `db:"device_id"` + CheckKey string `db:"check_key"` + Status DevicePostureStatus `db:"status"` + Evidence json.RawMessage `db:"evidence"` + ObservedAt time.Time `db:"observed_at"` + CreatedAt time.Time `db:"created_at"` + } + + DevicePostures []*DevicePosture +) + +const ( + devicePostureHistoryMaxLimit = 100 + devicePostureObservedAtClockSkew = 5 * time.Minute +) + +func (p DevicePosture) CursorKey(orderBy DevicePostureOrderField) page.CursorKey { + switch orderBy { + case DevicePostureOrderFieldCheckKey: + return page.NewCursorKey(p.ID, p.CheckKey) + } + + panic(fmt.Sprintf("unsupported order by: %s", orderBy)) +} + +func (p DevicePosture) Insert( + ctx context.Context, + conn pg.Tx, + scope Scoper, +) error { + evidence := p.Evidence + if len(evidence) == 0 { + evidence = emptyJSONObject + } + + now := p.CreatedAt + if now.IsZero() { + now = time.Now() + } + + observedAt := normalizeObservedAt(p.ObservedAt, now) + + q := ` +INSERT INTO device_postures ( + id, + tenant_id, + organization_id, + device_id, + check_key, + status, + evidence, + observed_at, + created_at +) VALUES ( + @id, + @tenant_id, + @organization_id, + @device_id, + @check_key, + @status, + @evidence, + @observed_at, + @created_at +) +` + args := pgx.StrictNamedArgs{ + "id": p.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": p.OrganizationID, + "device_id": p.DeviceID, + "check_key": p.CheckKey, + "status": p.Status, + "evidence": evidence, + "observed_at": observedAt, + "created_at": p.CreatedAt, + } + + if _, err := conn.Exec(ctx, q, args); err != nil { + return fmt.Errorf("cannot insert device posture: %w", err) + } + + return nil +} + +// normalizeObservedAt bounds client-supplied observation times before +// persistence. Zero timestamps default to now; values beyond the clock-skew +// allowance are clamped to now so a far-future observed_at cannot remain the +// latest result indefinitely. +func normalizeObservedAt(observed, now time.Time) time.Time { + if observed.IsZero() { + return now + } + + if observed.After(now.Add(devicePostureObservedAtClockSkew)) { + return now + } + + return observed +} + +// LoadLatestByDeviceID loads a page of the latest posture row for each +// check_key on the given device. +func (p *DevicePostures) LoadLatestByDeviceID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + deviceID gid.GID, + cursor *page.Cursor[DevicePostureOrderField], +) error { + q := ` +WITH latest AS ( + SELECT DISTINCT ON (check_key) + id, + tenant_id, + organization_id, + device_id, + check_key, + status, + evidence, + observed_at, + created_at + FROM + device_postures + WHERE + %s + AND device_id = @device_id + ORDER BY check_key, observed_at DESC +) +SELECT * FROM latest WHERE %s +` + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + + args := pgx.StrictNamedArgs{"device_id": deviceID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query latest device postures: %w", err) + } + + postures, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DevicePosture]) + if err != nil { + return fmt.Errorf("cannot collect device postures: %w", err) + } + + *p = postures + + return nil +} + +// LoadHistoryByDeviceIDAndCheckKey returns the most recent N entries for one +// (device, check_key) pair, newest first. +func (p *DevicePostures) LoadHistoryByDeviceIDAndCheckKey( + ctx context.Context, + conn pg.Querier, + scope Scoper, + deviceID gid.GID, + checkKey string, + limit int, +) error { + if limit <= 0 || limit > devicePostureHistoryMaxLimit { + limit = devicePostureHistoryMaxLimit + } + + q := ` +SELECT + id, + tenant_id, + organization_id, + device_id, + check_key, + status, + evidence, + observed_at, + created_at +FROM + device_postures +WHERE + %s + AND device_id = @device_id + AND check_key = @check_key +ORDER BY observed_at DESC +LIMIT @limit +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "device_id": deviceID, + "check_key": checkKey, + "limit": limit, + } + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query device posture history: %w", err) + } + + postures, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DevicePosture]) + if err != nil { + return fmt.Errorf("cannot collect device posture history: %w", err) + } + + *p = postures + + return nil +} diff --git a/pkg/coredata/device_posture_check_key.go b/pkg/coredata/device_posture_check_key.go new file mode 100644 index 000000000..b108455a2 --- /dev/null +++ b/pkg/coredata/device_posture_check_key.go @@ -0,0 +1,100 @@ +// 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 coredata + +import ( + "encoding" + "fmt" +) + +// DevicePostureCheckKey is a string identifier for a single posture check. +// New keys can be added freely without a database migration; the column is a +// plain TEXT. +type DevicePostureCheckKey string + +const ( + DevicePostureCheckKeyDiskEncryption DevicePostureCheckKey = "DISK_ENCRYPTION" + DevicePostureCheckKeyScreenLock DevicePostureCheckKey = "SCREEN_LOCK" + DevicePostureCheckKeyFirewallEnabled DevicePostureCheckKey = "FIREWALL_ENABLED" + DevicePostureCheckKeyTimeSync DevicePostureCheckKey = "TIME_SYNC" + DevicePostureCheckKeyOSVersion DevicePostureCheckKey = "OS_VERSION" + DevicePostureCheckKeyAutoUpdate DevicePostureCheckKey = "AUTO_UPDATE" + DevicePostureCheckKeyPasswordPolicy DevicePostureCheckKey = "PASSWORD_POLICY" + DevicePostureCheckKeyRemoteLogin DevicePostureCheckKey = "REMOTE_LOGIN" + DevicePostureCheckKeyMalwareProtection DevicePostureCheckKey = "MALWARE_PROTECTION" +) + +var ( + _ fmt.Stringer = DevicePostureCheckKey("") + _ encoding.TextMarshaler = DevicePostureCheckKey("") + _ encoding.TextUnmarshaler = (*DevicePostureCheckKey)(nil) +) + +func DevicePostureCheckKeys() []DevicePostureCheckKey { + return []DevicePostureCheckKey{ + DevicePostureCheckKeyDiskEncryption, + DevicePostureCheckKeyScreenLock, + DevicePostureCheckKeyFirewallEnabled, + DevicePostureCheckKeyTimeSync, + DevicePostureCheckKeyOSVersion, + DevicePostureCheckKeyAutoUpdate, + DevicePostureCheckKeyPasswordPolicy, + DevicePostureCheckKeyRemoteLogin, + DevicePostureCheckKeyMalwareProtection, + } +} + +func (v DevicePostureCheckKey) IsValid() bool { + switch v { + case + DevicePostureCheckKeyDiskEncryption, + DevicePostureCheckKeyScreenLock, + DevicePostureCheckKeyFirewallEnabled, + DevicePostureCheckKeyTimeSync, + DevicePostureCheckKeyOSVersion, + DevicePostureCheckKeyAutoUpdate, + DevicePostureCheckKeyPasswordPolicy, + DevicePostureCheckKeyRemoteLogin, + DevicePostureCheckKeyMalwareProtection: + return true + } + + return false +} + +func (v DevicePostureCheckKey) String() string { + return string(v) +} + +func (v DevicePostureCheckKey) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v *DevicePostureCheckKey) UnmarshalText(text []byte) error { + val := DevicePostureCheckKey(text) + if !val.IsValid() { + return fmt.Errorf("invalid DevicePostureCheckKey value: %q", string(text)) + } + + *v = val + + return nil +} diff --git a/pkg/coredata/device_posture_observed_at_test.go b/pkg/coredata/device_posture_observed_at_test.go new file mode 100644 index 000000000..666304943 --- /dev/null +++ b/pkg/coredata/device_posture_observed_at_test.go @@ -0,0 +1,80 @@ +// 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 coredata + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestNormalizeObservedAt(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 7, 15, 10, 0, 0, 0, time.UTC) + + tests := []struct { + name string + observed time.Time + want time.Time + }{ + { + name: "zero clamps to now", + observed: time.Time{}, + want: now, + }, + { + name: "past is unchanged", + observed: now.Add(-time.Hour), + want: now.Add(-time.Hour), + }, + { + name: "slightly future within skew is unchanged", + observed: now.Add(2 * time.Minute), + want: now.Add(2 * time.Minute), + }, + { + name: "far future beyond skew clamps to now", + observed: now.Add(time.Hour), + want: now, + }, + { + name: "exactly at skew boundary is unchanged", + observed: now.Add(devicePostureObservedAtClockSkew), + want: now.Add(devicePostureObservedAtClockSkew), + }, + { + name: "just beyond skew boundary clamps to now", + observed: now.Add(devicePostureObservedAtClockSkew + time.Nanosecond), + want: now, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := normalizeObservedAt(tt.observed, now) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/coredata/device_posture_order_field.go b/pkg/coredata/device_posture_order_field.go new file mode 100644 index 000000000..cd0d00576 --- /dev/null +++ b/pkg/coredata/device_posture_order_field.go @@ -0,0 +1,84 @@ +// 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 coredata + +import ( + "encoding" + "fmt" + + "go.probo.inc/probo/pkg/page" +) + +type DevicePostureOrderField string + +const ( + DevicePostureOrderFieldCheckKey DevicePostureOrderField = "CHECK_KEY" +) + +var ( + _ page.OrderField = DevicePostureOrderField("") + _ fmt.Stringer = DevicePostureOrderField("") + _ encoding.TextMarshaler = DevicePostureOrderField("") + _ encoding.TextUnmarshaler = (*DevicePostureOrderField)(nil) +) + +func DevicePostureOrderFields() []DevicePostureOrderField { + return []DevicePostureOrderField{ + DevicePostureOrderFieldCheckKey, + } +} + +func (v DevicePostureOrderField) IsValid() bool { + switch v { + case DevicePostureOrderFieldCheckKey: + return true + } + + return false +} + +func (v DevicePostureOrderField) String() string { + return string(v) +} + +func (v DevicePostureOrderField) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v *DevicePostureOrderField) UnmarshalText(text []byte) error { + val := DevicePostureOrderField(text) + if !val.IsValid() { + return fmt.Errorf("invalid DevicePostureOrderField value: %q", string(text)) + } + + *v = val + + return nil +} + +func (f DevicePostureOrderField) Column() string { + switch f { + case DevicePostureOrderFieldCheckKey: + return "check_key" + } + + panic(fmt.Sprintf("unsupported order by: %s", f)) +} diff --git a/pkg/coredata/device_posture_status.go b/pkg/coredata/device_posture_status.go new file mode 100644 index 000000000..06278d3b5 --- /dev/null +++ b/pkg/coredata/device_posture_status.go @@ -0,0 +1,82 @@ +// 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 coredata + +import ( + "encoding" + "fmt" +) + +type DevicePostureStatus string + +const ( + DevicePostureStatusPass DevicePostureStatus = "PASS" + DevicePostureStatusFail DevicePostureStatus = "FAIL" + DevicePostureStatusUnknown DevicePostureStatus = "UNKNOWN" + DevicePostureStatusNotApplicable DevicePostureStatus = "NOT_APPLICABLE" +) + +var ( + _ fmt.Stringer = DevicePostureStatus("") + _ encoding.TextMarshaler = DevicePostureStatus("") + _ encoding.TextUnmarshaler = (*DevicePostureStatus)(nil) +) + +func DevicePostureStatuses() []DevicePostureStatus { + return []DevicePostureStatus{ + DevicePostureStatusPass, + DevicePostureStatusFail, + DevicePostureStatusUnknown, + DevicePostureStatusNotApplicable, + } +} + +func (v DevicePostureStatus) IsValid() bool { + switch v { + case + DevicePostureStatusPass, + DevicePostureStatusFail, + DevicePostureStatusUnknown, + DevicePostureStatusNotApplicable: + return true + } + + return false +} + +func (v DevicePostureStatus) String() string { + return string(v) +} + +func (v DevicePostureStatus) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v *DevicePostureStatus) UnmarshalText(text []byte) error { + val := DevicePostureStatus(text) + if !val.IsValid() { + return fmt.Errorf("invalid DevicePostureStatus value: %q", string(text)) + } + + *v = val + + return nil +} diff --git a/pkg/coredata/device_posture_test.go b/pkg/coredata/device_posture_test.go new file mode 100644 index 000000000..d14b0497f --- /dev/null +++ b/pkg/coredata/device_posture_test.go @@ -0,0 +1,210 @@ +// 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 coredata_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/internal/test" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type devicePostureFixture struct { + scope *coredata.Scope + organizationID gid.GID + deviceID gid.GID +} + +func seedDevicePostureFixture(t *testing.T, ctx context.Context, client *pg.Client) devicePostureFixture { + t.Helper() + + tenantID := gid.NewTenantID() + scope := coredata.NewScope(tenantID) + organizationID := gid.New(tenantID, coredata.OrganizationEntityType) + deviceID := gid.New(tenantID, coredata.DeviceEntityType) + now := time.Now().UTC().Truncate(time.Microsecond) + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + org := &coredata.Organization{ + ID: organizationID, + TenantID: tenantID, + Name: "Device Posture Test Org", + CreatedAt: now, + UpdatedAt: now, + } + if err := org.Insert(ctx, tx); err != nil { + return err + } + + device := coredata.Device{ + ID: deviceID, + OrganizationID: organizationID, + State: coredata.DeviceStatePending, + APIKeyHash: []byte("device-posture-test-" + deviceID.String()), + CreatedAt: now, + UpdatedAt: now, + } + if err := device.Insert(ctx, tx, scope); err != nil { + return err + } + + return nil + })) + + t.Cleanup(func() { + _ = client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error { + if _, err := tx.Exec(ctx, `DELETE FROM device_postures WHERE device_id = $1`, deviceID); err != nil { + return err + } + + if _, err := tx.Exec(ctx, `DELETE FROM devices WHERE id = $1`, deviceID); err != nil { + return err + } + + if _, err := tx.Exec(ctx, `DELETE FROM organizations WHERE id = $1`, organizationID); err != nil { + return err + } + + return nil + }) + }) + + return devicePostureFixture{ + scope: scope, + organizationID: organizationID, + deviceID: deviceID, + } +} + +func insertDevicePosture( + t *testing.T, + ctx context.Context, + client *pg.Client, + fx devicePostureFixture, + checkKey string, + status coredata.DevicePostureStatus, + observedAt time.Time, +) { + t.Helper() + + posture := coredata.DevicePosture{ + ID: gid.New(fx.scope.GetTenantID(), coredata.DevicePostureEntityType), + OrganizationID: fx.organizationID, + DeviceID: fx.deviceID, + CheckKey: checkKey, + Status: status, + ObservedAt: observedAt, + CreatedAt: observedAt, + } + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + return posture.Insert(ctx, tx, fx.scope) + })) +} + +func TestDevicePosture_LoadLatestByDeviceID_ReturnsLatestPerCheckKey(t *testing.T) { + t.Parallel() + + client := test.PGClient(t) + ctx := context.Background() + fx := seedDevicePostureFixture(t, ctx, client) + + now := time.Now().UTC().Truncate(time.Microsecond) + + insertDevicePosture(t, ctx, client, fx, "AUTO_UPDATE", coredata.DevicePostureStatusFail, now.Add(-2*time.Hour)) + insertDevicePosture(t, ctx, client, fx, "AUTO_UPDATE", coredata.DevicePostureStatusPass, now.Add(-time.Hour)) + insertDevicePosture(t, ctx, client, fx, "DISK_ENCRYPTION", coredata.DevicePostureStatusUnknown, now.Add(-30*time.Minute)) + insertDevicePosture(t, ctx, client, fx, "FIREWALL_ENABLED", coredata.DevicePostureStatusFail, now.Add(-time.Minute)) + + var ( + pageOne coredata.DevicePostures + hasNext bool + ) + + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + orderBy := page.OrderBy[coredata.DevicePostureOrderField]{ + Field: coredata.DevicePostureOrderFieldCheckKey, + Direction: page.OrderDirectionAsc, + } + cursor := page.NewCursor(2, nil, page.Head, orderBy) + + var batch coredata.DevicePostures + if err := batch.LoadLatestByDeviceID(ctx, conn, fx.scope, fx.deviceID, cursor); err != nil { + return err + } + + p := page.NewPage(batch, cursor) + pageOne = p.Data + hasNext = p.Info.HasNext + + return nil + })) + require.Len(t, pageOne, 2) + require.True(t, hasNext) + assert.Equal(t, "AUTO_UPDATE", pageOne[0].CheckKey) + assert.Equal(t, coredata.DevicePostureStatusPass, pageOne[0].Status) + assert.Equal(t, "DISK_ENCRYPTION", pageOne[1].CheckKey) + + var all coredata.DevicePostures + + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + loaded, err := page.LoadAll( + ctx, + page.OrderBy[coredata.DevicePostureOrderField]{ + Field: coredata.DevicePostureOrderFieldCheckKey, + Direction: page.OrderDirectionAsc, + }, + func(ctx context.Context, cursor *page.Cursor[coredata.DevicePostureOrderField]) ([]*coredata.DevicePosture, error) { + var batch coredata.DevicePostures + if err := batch.LoadLatestByDeviceID(ctx, conn, fx.scope, fx.deviceID, cursor); err != nil { + return nil, fmt.Errorf("cannot load latest device postures: %w", err) + } + + return batch, nil + }, + ) + if err != nil { + return err + } + + all = loaded + + return nil + })) + require.Len(t, all, 3) + + byKey := make(map[string]coredata.DevicePostureStatus, len(all)) + for _, posture := range all { + byKey[posture.CheckKey] = posture.Status + } + + assert.Equal(t, coredata.DevicePostureStatusPass, byKey["AUTO_UPDATE"]) + assert.Equal(t, coredata.DevicePostureStatusUnknown, byKey["DISK_ENCRYPTION"]) + assert.Equal(t, coredata.DevicePostureStatusFail, byKey["FIREWALL_ENABLED"]) +} diff --git a/pkg/coredata/device_state.go b/pkg/coredata/device_state.go new file mode 100644 index 000000000..d662c2404 --- /dev/null +++ b/pkg/coredata/device_state.go @@ -0,0 +1,79 @@ +// 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 coredata + +import ( + "encoding" + "fmt" +) + +type DeviceState string + +const ( + DeviceStatePending DeviceState = "PENDING" + DeviceStateActive DeviceState = "ACTIVE" + DeviceStateRevoked DeviceState = "REVOKED" +) + +var ( + _ fmt.Stringer = DeviceState("") + _ encoding.TextMarshaler = DeviceState("") + _ encoding.TextUnmarshaler = (*DeviceState)(nil) +) + +func DeviceStates() []DeviceState { + return []DeviceState{ + DeviceStatePending, + DeviceStateActive, + DeviceStateRevoked, + } +} + +func (v DeviceState) IsValid() bool { + switch v { + case + DeviceStatePending, + DeviceStateActive, + DeviceStateRevoked: + return true + } + + return false +} + +func (v DeviceState) String() string { + return string(v) +} + +func (v DeviceState) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v *DeviceState) UnmarshalText(text []byte) error { + val := DeviceState(text) + if !val.IsValid() { + return fmt.Errorf("invalid DeviceState value: %q", string(text)) + } + + *v = val + + return nil +} diff --git a/pkg/coredata/entity_type_reg.go b/pkg/coredata/entity_type_reg.go index e4b01d482..b8dcc9dc0 100644 --- a/pkg/coredata/entity_type_reg.go +++ b/pkg/coredata/entity_type_reg.go @@ -136,6 +136,9 @@ const ( CompliancePortalCommitmentGroupEntityType uint16 = 104 CompliancePortalCommitmentEntityType uint16 = 105 CertificateEntityType uint16 = 106 + DeviceEntityType uint16 = 107 + DevicePostureEntityType uint16 = 108 + DeviceEnrollmentTokenEntityType uint16 = 109 ) func NewEntityFromID(id gid.GID) (any, bool) { @@ -336,6 +339,12 @@ func NewEntityFromID(id gid.GID) (any, bool) { return &CompliancePortalCommitment{ID: id}, true case CertificateEntityType: return &Certificate{ID: id}, true + case DeviceEntityType: + return &Device{ID: id}, true + case DevicePostureEntityType: + return &DevicePosture{ID: id}, true + case DeviceEnrollmentTokenEntityType: + return &DeviceEnrollmentToken{ID: id}, true default: return nil, false } diff --git a/pkg/coredata/migrations/20260710T120000Z.sql b/pkg/coredata/migrations/20260710T120000Z.sql new file mode 100644 index 000000000..693aee4ff --- /dev/null +++ b/pkg/coredata/migrations/20260710T120000Z.sql @@ -0,0 +1,106 @@ +-- 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. + +CREATE TYPE device_platform AS ENUM ( + 'DARWIN', + 'LINUX', + 'FREEBSD', + 'WINDOWS' +); + +CREATE TYPE device_posture_status AS ENUM ( + 'PASS', + 'FAIL', + 'UNKNOWN', + 'NOT_APPLICABLE' +); + +CREATE TYPE device_state AS ENUM ( + 'PENDING', + 'ACTIVE', + 'REVOKED' +); + +CREATE TABLE devices ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON UPDATE CASCADE ON DELETE CASCADE, + state device_state NOT NULL DEFAULT 'PENDING', + hardware_uuid TEXT, + serial_number TEXT, + hostname TEXT, + platform device_platform, + os_version TEXT, + agent_version TEXT, + api_key_hash BYTEA NOT NULL, + owner_profile_id TEXT REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE RESTRICT, + labels JSONB NOT NULL DEFAULT '{}'::jsonb, + enrolled_at TIMESTAMP WITH TIME ZONE, + last_seen_at TIMESTAMP WITH TIME ZONE, + revoked_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT devices_active_fields_check CHECK ( + state != 'ACTIVE' + OR ( + hardware_uuid IS NOT NULL + AND hostname IS NOT NULL + AND platform IS NOT NULL + AND os_version IS NOT NULL + AND agent_version IS NOT NULL + AND enrolled_at IS NOT NULL + AND last_seen_at IS NOT NULL + ) + ), + CONSTRAINT devices_revoked_at_check CHECK ( + (state = 'REVOKED' AND revoked_at IS NOT NULL) + OR (state != 'REVOKED' AND revoked_at IS NULL) + ) +); + +CREATE UNIQUE INDEX devices_org_hardware_uuid_idx + ON devices (organization_id, hardware_uuid) + WHERE hardware_uuid IS NOT NULL + AND state != 'REVOKED'; + +CREATE UNIQUE INDEX devices_api_key_hash_idx + ON devices (api_key_hash); + +CREATE INDEX devices_owner_profile_idx + ON devices (owner_profile_id) + WHERE owner_profile_id IS NOT NULL; + +CREATE TABLE device_postures ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON UPDATE CASCADE ON DELETE CASCADE, + device_id TEXT NOT NULL REFERENCES devices(id) ON UPDATE CASCADE ON DELETE CASCADE, + check_key TEXT NOT NULL, + status device_posture_status NOT NULL, + evidence JSONB NOT NULL DEFAULT '{}'::jsonb, + observed_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE INDEX device_postures_device_id_check_key_observed_at_idx + ON device_postures (device_id, check_key, observed_at DESC); + +CREATE INDEX device_postures_organization_id_idx + ON device_postures (organization_id); diff --git a/pkg/coredata/migrations/20260711T094136Z.sql b/pkg/coredata/migrations/20260711T094136Z.sql new file mode 100644 index 000000000..5b918bc53 --- /dev/null +++ b/pkg/coredata/migrations/20260711T094136Z.sql @@ -0,0 +1,41 @@ +-- 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. + +CREATE TABLE device_enrollment_tokens ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + device_id TEXT NOT NULL REFERENCES devices(id) ON UPDATE CASCADE ON DELETE CASCADE, + hashed_value BYTEA NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT device_enrollment_tokens_hashed_value_unique UNIQUE (hashed_value) +); + +CREATE INDEX device_enrollment_tokens_device_id_idx + ON device_enrollment_tokens (device_id); + +ALTER TABLE devices + ALTER COLUMN api_key_hash DROP NOT NULL; + +ALTER TABLE devices + ADD CONSTRAINT devices_active_api_key_hash_check CHECK ( + state != 'ACTIVE' + OR api_key_hash IS NOT NULL + ); diff --git a/pkg/itam/actions.go b/pkg/itam/actions.go new file mode 100644 index 000000000..6f8ad8236 --- /dev/null +++ b/pkg/itam/actions.go @@ -0,0 +1,37 @@ +// 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 itam + +// ITAM Service Actions +// Format: itam:: +const ( + // Device actions + ActionDeviceList = "itam:device:list" + ActionEmployeeDeviceList = "itam:employee-device:list" + ActionDeviceGet = "itam:device:get" + ActionDeviceCreate = "itam:device:create" + ActionDeviceEnroll = "itam:device:enroll" + ActionDeviceRevoke = "itam:device:revoke" + ActionDeviceAssignOwner = "itam:device:assign" + + // DevicePosture actions + ActionDevicePostureList = "itam:device-posture:list" +) diff --git a/pkg/itam/gc.go b/pkg/itam/gc.go new file mode 100644 index 000000000..989aa3cef --- /dev/null +++ b/pkg/itam/gc.go @@ -0,0 +1,112 @@ +// 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 itam + +import ( + "context" + "fmt" + "sync/atomic" + "time" + + "go.gearno.de/kit/log" + "go.gearno.de/kit/pg" + "go.gearno.de/kit/worker" + "go.probo.inc/probo/pkg/coredata" +) + +const ( + DefaultGCInterval = 5 * time.Minute +) + +type GarbageCollector = worker.Worker[struct{}] + +type gcHandler struct { + pg *pg.Client + logger *log.Logger + lastRunAt atomic.Int64 +} + +func NewGarbageCollector( + pgClient *pg.Client, + logger *log.Logger, + opts ...worker.Option, +) *GarbageCollector { + h := &gcHandler{ + pg: pgClient, + logger: logger.Named("itam.garbage_collector"), + } + + return worker.New( + "itam.garbage_collector", + h, + logger, + append( + []worker.Option{ + worker.WithInterval(DefaultGCInterval), + worker.WithMaxConcurrency(1), + }, + opts..., + )..., + ) +} + +func (h *gcHandler) Claim(_ context.Context) (struct{}, error) { + now := time.Now().UnixNano() + last := h.lastRunAt.Load() + + if last > 0 && now-last < int64(DefaultGCInterval) { + return struct{}{}, worker.ErrNoTask + } + + if !h.lastRunAt.CompareAndSwap(last, now) { + return struct{}{}, worker.ErrNoTask + } + + return struct{}{}, nil +} + +func (h *gcHandler) Process(ctx context.Context, _ struct{}) error { + return h.cleanup(ctx) +} + +func (h *gcHandler) cleanup(ctx context.Context) error { + now := time.Now() + + return h.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + var token coredata.DeviceEnrollmentToken + + tokensDeleted, err := token.DeleteExpired(ctx, tx, now) + if err != nil { + return fmt.Errorf("cannot delete expired device enrollment tokens: %w", err) + } + + h.logger.InfoCtx( + ctx, + "itam garbage collector cleaned up", + log.Int64("device_enrollment_tokens_deleted", tokensDeleted), + ) + + return nil + }, + ) +} diff --git a/pkg/itam/policies.go b/pkg/itam/policies.go new file mode 100644 index 000000000..3820226d3 --- /dev/null +++ b/pkg/itam/policies.go @@ -0,0 +1,78 @@ +// 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 itam + +import ( + "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/iam/policy" +) + +var ( + organizationCondition = policy.Equals("principal.organization_id", "resource.organization_id") + ownerCondition = policy.Equals("principal.id", "resource.owner_id") +) + +// FullAccessPolicy grants complete ITAM access to organization owners and +// admins. +var FullAccessPolicy = policy.NewPolicy( + "itam:full-access", + "ITAM Full Access", + policy.Allow( + ActionDeviceList, ActionEmployeeDeviceList, ActionDeviceGet, ActionDeviceCreate, + ActionDeviceEnroll, ActionDeviceRevoke, ActionDeviceAssignOwner, + ActionDevicePostureList, + ).WithSID("itam-full-access").When(organizationCondition), +).WithDescription("Full ITAM access for organization owners and admins") + +// ViewerPolicy grants read-only access to ITAM entities for organization +// viewers. +var ViewerPolicy = policy.NewPolicy( + "itam:viewer", + "ITAM Viewer", + policy.Allow( + ActionDeviceGet, ActionDeviceList, + ActionDevicePostureList, + ).WithSID("itam-read-access").When(organizationCondition), +).WithDescription("Read-only ITAM access for organization viewers") + +// EmployeePolicy grants self-enrollment access to organization employees. +var EmployeePolicy = policy.NewPolicy( + "itam:employee", + "ITAM Employee", + policy.Allow(ActionDeviceEnroll). + WithSID("itam-employee-enroll-device"). + When(organizationCondition), + policy.Allow(ActionDeviceGet). + WithSID("itam-employee-get-own-device"). + When(organizationCondition, ownerCondition), + policy.Allow(ActionEmployeeDeviceList). + WithSID("itam-employee-device-list"). + When(organizationCondition), +).WithDescription("Self-enrollment: enroll own device and read own enrolled devices") + +// ITAMPolicySet returns the PolicySet for the ITAM service. +func ITAMPolicySet() *iam.PolicySet { + return iam.NewPolicySet(). + AddRolePolicy("OWNER", FullAccessPolicy). + AddRolePolicy("ADMIN", FullAccessPolicy). + AddRolePolicy("VIEWER", ViewerPolicy). + AddRolePolicy("EMPLOYEE", EmployeePolicy) +} diff --git a/pkg/itam/service.go b/pkg/itam/service.go new file mode 100644 index 000000000..c06ff422a --- /dev/null +++ b/pkg/itam/service.go @@ -0,0 +1,856 @@ +// 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 itam + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "go.gearno.de/kit/log" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/crypto/hash" + "go.probo.inc/probo/pkg/crypto/rand" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/page" +) + +var ( + // ErrDeviceRevoked is returned when the authenticated device has + // been revoked. + ErrDeviceRevoked = errors.New("device is revoked") + + // ErrDeviceHardwareConflict is returned when activation would + // duplicate an existing (organization_id, hardware_uuid) pair. + ErrDeviceHardwareConflict = errors.New("device hardware uuid already enrolled") + + // ErrEnrollmentTokenExpired is returned when an enrollment token + // has passed its expiry time. + ErrEnrollmentTokenExpired = errors.New("enrollment token expired") + + // ErrEnrollmentTokenAlreadyUsed is returned when an enrollment + // token has already been exchanged. + ErrEnrollmentTokenAlreadyUsed = errors.New("enrollment token already used") + + // ErrEnrollmentTokenInvalid is returned when an enrollment token + // cannot be exchanged for the device. + ErrEnrollmentTokenInvalid = errors.New("enrollment token invalid") +) + +const ( + // APIKeyRawLength is the random byte length of a device API key + // secret (96 chars once hex-encoded). + APIKeyRawLength = 48 + + // EnrollmentTokenRawLength is the random byte length of a device + // enrollment token secret (96 chars once hex-encoded). + EnrollmentTokenRawLength = 48 +) + +type ( + // Service is the IT Asset Management service. Admin operations are + // tenant-scoped via a caller-supplied scope; agent-facing operations + // (authenticate, heartbeat, postures, unenroll) resolve their own + // scope, since the agent does not know its tenant until activation. + Service struct { + pg *pg.Client + logger *log.Logger + enrollmentTokenValidity time.Duration + } + + CreateDeviceRequest struct { + OrganizationID gid.GID + OwnerID *gid.GID + } + + EnrollDeviceRequest struct { + OrganizationID gid.GID + IdentityID gid.GID + } + + // CreateDeviceResult carries the device row and the plaintext + // enrollment token the agent installer must exchange for an API key. + // Only the hash is stored, so EnrollmentToken is available only at + // this point. + CreateDeviceResult struct { + Device *coredata.Device + EnrollmentToken string + } + + RecordHeartbeatRequest struct { + HardwareUUID string + SerialNumber *string + Hostname string + Platform coredata.DevicePlatform + OSVersion string + AgentVersion string + } + + RecordPostureResult struct { + CheckKey string + Status coredata.DevicePostureStatus + Evidence json.RawMessage + ObservedAt time.Time + } + + ServiceConfig struct { + EnrollmentTokenValidity time.Duration + } +) + +func NewService( + pgClient *pg.Client, + iamSvc *iam.Service, + cfg ServiceConfig, + logger *log.Logger, +) *Service { + iamSvc.Authorizer.RegisterPolicySet(ITAMPolicySet()) + + validity := cfg.EnrollmentTokenValidity + if validity <= 0 { + validity = 7 * 24 * time.Hour + } + + return &Service{ + pg: pgClient, + logger: logger, + enrollmentTokenValidity: validity, + } +} + +func (s *Service) CreateDevice( + ctx context.Context, + scope coredata.Scoper, + req CreateDeviceRequest, +) (*CreateDeviceResult, error) { + enrollmentToken, err := rand.HexString(EnrollmentTokenRawLength) + if err != nil { + return nil, err + } + + enrollmentTokenHash := hash.SHA256String(enrollmentToken) + now := time.Now() + + device := &coredata.Device{ + ID: gid.New(req.OrganizationID.TenantID(), coredata.DeviceEntityType), + OrganizationID: req.OrganizationID, + State: coredata.DeviceStatePending, + CreatedAt: now, + UpdatedAt: now, + } + + token := &coredata.DeviceEnrollmentToken{ + ID: gid.New(req.OrganizationID.TenantID(), coredata.DeviceEnrollmentTokenEntityType), + DeviceID: device.ID, + HashedValue: enrollmentTokenHash, + ExpiresAt: now.Add(s.enrollmentTokenValidity), + CreatedAt: now, + } + + err = s.pg.WithTx( + ctx, + func(ctx context.Context, conn pg.Tx) error { + organization := &coredata.Organization{} + if err := organization.LoadByID(ctx, conn, scope, req.OrganizationID); err != nil { + return fmt.Errorf("cannot load organization: %w", err) + } + + ownerID, err := s.validateOwnerProfileID( + ctx, + conn, + scope, + req.OrganizationID, + req.OwnerID, + ) + if err != nil { + return err + } + + device.OwnerID = ownerID + + if err := device.Insert(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot insert device: %w", err) + } + + if err := token.Insert(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot insert device enrollment token: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return &CreateDeviceResult{ + Device: device, + EnrollmentToken: enrollmentToken, + }, nil +} + +// EnrollDevice creates a pending device owned by the caller's membership +// profile in the organization. +func (s *Service) EnrollDevice( + ctx context.Context, + scope coredata.Scoper, + req EnrollDeviceRequest, +) (*CreateDeviceResult, error) { + enrollmentToken, err := rand.HexString(EnrollmentTokenRawLength) + if err != nil { + return nil, err + } + + enrollmentTokenHash := hash.SHA256String(enrollmentToken) + now := time.Now() + + device := &coredata.Device{ + ID: gid.New(req.OrganizationID.TenantID(), coredata.DeviceEntityType), + OrganizationID: req.OrganizationID, + State: coredata.DeviceStatePending, + CreatedAt: now, + UpdatedAt: now, + } + + token := &coredata.DeviceEnrollmentToken{ + ID: gid.New(req.OrganizationID.TenantID(), coredata.DeviceEnrollmentTokenEntityType), + DeviceID: device.ID, + HashedValue: enrollmentTokenHash, + ExpiresAt: now.Add(s.enrollmentTokenValidity), + CreatedAt: now, + } + + err = s.pg.WithTx( + ctx, + func(ctx context.Context, conn pg.Tx) error { + organization := &coredata.Organization{} + if err := organization.LoadByID(ctx, conn, scope, req.OrganizationID); err != nil { + return fmt.Errorf("cannot load organization: %w", err) + } + + profile := &coredata.MembershipProfile{} + if err := profile.LoadByIdentityIDAndOrganizationID( + ctx, + conn, + scope, + req.IdentityID, + req.OrganizationID, + ); err != nil { + return fmt.Errorf("cannot load owner profile for identity: %w", err) + } + + device.OwnerID = &profile.ID + + if err := device.Insert(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot insert device: %w", err) + } + + if err := token.Insert(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot insert device enrollment token: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return &CreateDeviceResult{ + Device: device, + EnrollmentToken: enrollmentToken, + }, nil +} + +// ExchangeEnrollmentToken redeems a one-shot enrollment token and returns +// the plaintext device API key. The token row is deleted on success. +func (s *Service) ExchangeEnrollmentToken( + ctx context.Context, + tokenString string, +) (string, error) { + hashedValue := hash.SHA256String(tokenString) + now := time.Now() + + var apiKey string + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, conn pg.Tx) error { + token := &coredata.DeviceEnrollmentToken{} + if err := token.LoadByHashedValueForUpdate(ctx, conn, hashedValue); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrEnrollmentTokenAlreadyUsed + } + + return fmt.Errorf("cannot load device enrollment token: %w", err) + } + + if now.After(token.ExpiresAt) { + if err := token.Delete(ctx, conn); err != nil { + return fmt.Errorf("cannot delete expired device enrollment token: %w", err) + } + + return ErrEnrollmentTokenExpired + } + + scope := coredata.NewScope(token.TenantID) + + device := &coredata.Device{} + if err := device.LoadByIDForUpdate(ctx, conn, scope, token.DeviceID); err != nil { + return fmt.Errorf("cannot load device: %w", err) + } + + if device.State == coredata.DeviceStateRevoked { + return ErrEnrollmentTokenInvalid + } + + if len(device.APIKeyHash) > 0 { + return ErrEnrollmentTokenInvalid + } + + generatedKey, err := rand.HexString(APIKeyRawLength) + if err != nil { + return err + } + + apiKeyHash := hash.SHA256String(generatedKey) + if err := device.SetAPIKeyHash(ctx, conn, scope, apiKeyHash); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrEnrollmentTokenInvalid + } + + return fmt.Errorf("cannot set device api key hash: %w", err) + } + + if err := token.Delete(ctx, conn); err != nil { + return fmt.Errorf("cannot delete device enrollment token: %w", err) + } + + apiKey = generatedKey + + return nil + }, + ) + if err != nil { + return "", err + } + + return apiKey, nil +} + +func (s *Service) GetDevice( + ctx context.Context, + scope coredata.Scoper, + deviceID gid.GID, +) (*coredata.Device, error) { + device := &coredata.Device{} + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := device.LoadByID(ctx, conn, scope, deviceID); err != nil { + return fmt.Errorf("cannot load device: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return device, nil +} + +func (s *Service) ListForOrganizationID( + ctx context.Context, + scope coredata.Scoper, + organizationID gid.GID, + cursor *page.Cursor[coredata.DeviceOrderField], +) (*page.Page[*coredata.Device, coredata.DeviceOrderField], error) { + var devices coredata.Devices + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + organization := &coredata.Organization{} + if err := organization.LoadByID(ctx, conn, scope, organizationID); err != nil { + return fmt.Errorf("cannot load organization: %w", err) + } + + if err := devices.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor); err != nil { + return fmt.Errorf("cannot load devices: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return page.NewPage(devices, cursor), nil +} + +func (s *Service) ListForOrganizationIDAndOwnerID( + ctx context.Context, + scope coredata.Scoper, + organizationID gid.GID, + ownerID gid.GID, + cursor *page.Cursor[coredata.DeviceOrderField], +) (*page.Page[*coredata.Device, coredata.DeviceOrderField], error) { + var devices coredata.Devices + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + organization := &coredata.Organization{} + if err := organization.LoadByID(ctx, conn, scope, organizationID); err != nil { + return fmt.Errorf("cannot load organization: %w", err) + } + + if err := devices.LoadByOrganizationIDAndOwnerID( + ctx, conn, scope, organizationID, ownerID, cursor, + ); err != nil { + return fmt.Errorf("cannot load devices by owner: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return page.NewPage(devices, cursor), nil +} + +func (s *Service) CountForOrganizationID( + ctx context.Context, + scope coredata.Scoper, + organizationID gid.GID, +) (int, error) { + var count int + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + organization := &coredata.Organization{} + if err := organization.LoadByID(ctx, conn, scope, organizationID); err != nil { + return fmt.Errorf("cannot load organization: %w", err) + } + + var ds coredata.Devices + + c, err := ds.CountByOrganizationID(ctx, conn, scope, organizationID) + if err != nil { + return fmt.Errorf("cannot count devices: %w", err) + } + + count = c + + return nil + }, + ) + + return count, err +} + +func (s *Service) CountForOrganizationIDAndOwnerID( + ctx context.Context, + scope coredata.Scoper, + organizationID gid.GID, + ownerID gid.GID, +) (int, error) { + var count int + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + organization := &coredata.Organization{} + if err := organization.LoadByID(ctx, conn, scope, organizationID); err != nil { + return fmt.Errorf("cannot load organization: %w", err) + } + + var ds coredata.Devices + + c, err := ds.CountByOrganizationIDAndOwnerID( + ctx, conn, scope, organizationID, ownerID, + ) + if err != nil { + return fmt.Errorf("cannot count devices by owner: %w", err) + } + + count = c + + return nil + }, + ) + + return count, err +} + +func (s *Service) RevokeDevice( + ctx context.Context, + scope coredata.Scoper, + deviceID gid.GID, +) (*coredata.Device, error) { + device := &coredata.Device{} + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, conn pg.Tx) error { + if err := device.LoadByID(ctx, conn, scope, deviceID); err != nil { + return fmt.Errorf("cannot load device: %w", err) + } + + if err := device.Revoke(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot revoke device: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return device, nil +} + +func (s *Service) SetDeviceOwner( + ctx context.Context, + scope coredata.Scoper, + deviceID gid.GID, + ownerProfileID *gid.GID, +) (*coredata.Device, error) { + device := &coredata.Device{} + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, conn pg.Tx) error { + if err := device.LoadByID(ctx, conn, scope, deviceID); err != nil { + return fmt.Errorf("cannot load device: %w", err) + } + + resolvedOwnerID, err := s.validateOwnerProfileID( + ctx, conn, scope, device.OrganizationID, ownerProfileID, + ) + if err != nil { + return err + } + + if err := device.AssignOwner(ctx, conn, scope, resolvedOwnerID); err != nil { + return fmt.Errorf("cannot set device owner: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return device, nil +} + +func (s *Service) validateOwnerProfileID( + ctx context.Context, + conn pg.Querier, + scope coredata.Scoper, + organizationID gid.GID, + ownerID *gid.GID, +) (*gid.GID, error) { + if ownerID == nil { + return nil, nil + } + + if ownerID.EntityType() != coredata.MembershipProfileEntityType { + return nil, fmt.Errorf("owner_id must be a membership profile") + } + + profile := &coredata.MembershipProfile{} + if err := profile.LoadByID(ctx, conn, scope, *ownerID); err != nil { + return nil, fmt.Errorf("cannot load owner profile: %w", err) + } + + if profile.OrganizationID != organizationID { + return nil, fmt.Errorf("owner profile does not belong to organization") + } + + return ownerID, nil +} + +func (s *Service) GetLatestPostures( + ctx context.Context, + scope coredata.Scoper, + deviceID gid.GID, +) (coredata.DevicePostures, error) { + var postures coredata.DevicePostures + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + loaded, err := page.LoadAll( + ctx, + page.OrderBy[coredata.DevicePostureOrderField]{ + Field: coredata.DevicePostureOrderFieldCheckKey, + Direction: page.OrderDirectionAsc, + }, + func(ctx context.Context, cursor *page.Cursor[coredata.DevicePostureOrderField]) ([]*coredata.DevicePosture, error) { + var batch coredata.DevicePostures + if err := batch.LoadLatestByDeviceID(ctx, conn, scope, deviceID, cursor); err != nil { + return nil, fmt.Errorf("cannot load latest device postures: %w", err) + } + + return batch, nil + }, + ) + if err != nil { + return fmt.Errorf("cannot load latest device postures: %w", err) + } + + postures = loaded + + return nil + }, + ) + if err != nil { + return nil, err + } + + return postures, nil +} + +func (s *Service) GetPostureHistory( + ctx context.Context, + scope coredata.Scoper, + deviceID gid.GID, + checkKey string, + limit int, +) (coredata.DevicePostures, error) { + var postures coredata.DevicePostures + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := postures.LoadHistoryByDeviceIDAndCheckKey(ctx, conn, scope, deviceID, checkKey, limit); err != nil { + return fmt.Errorf("cannot load device posture history: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return postures, nil +} + +// AuthenticateDevice resolves a device API key to its device row. +// Returns coredata.ErrResourceNotFound when no non-revoked device +// matches the key. Revoked devices are treated as not found. +func (s *Service) AuthenticateDevice( + ctx context.Context, + apiKey string, +) (*coredata.Device, error) { + if apiKey == "" { + return nil, coredata.ErrResourceNotFound + } + + hash := hash.SHA256String(apiKey) + + device := &coredata.Device{} + + err := s.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return device.LoadByAPIKeyHash(ctx, conn, hash) + }) + if err != nil { + return nil, err + } + + return device, nil +} + +// RecordHeartbeat refreshes the device's last-seen timestamp and any +// version fields the agent sends. On the first heartbeat for a PENDING +// device, hardware metadata is recorded and the device is activated. +func (s *Service) RecordHeartbeat( + ctx context.Context, + scope coredata.Scoper, + deviceID gid.GID, + req RecordHeartbeatRequest, +) (*coredata.Device, error) { + if req.HardwareUUID == "" { + return nil, fmt.Errorf("hardware_uuid is required") + } + + if req.Hostname == "" { + return nil, fmt.Errorf("hostname is required") + } + + if !req.Platform.IsValid() { + return nil, fmt.Errorf("invalid platform: %q", req.Platform) + } + + if req.OSVersion == "" { + return nil, fmt.Errorf("os_version is required") + } + + if req.AgentVersion == "" { + return nil, fmt.Errorf("agent_version is required") + } + + device := &coredata.Device{} + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, conn pg.Tx) error { + if err := device.LoadByIDForUpdate(ctx, conn, scope, deviceID); err != nil { + return fmt.Errorf("cannot load device: %w", err) + } + + if device.State == coredata.DeviceStateRevoked { + return ErrDeviceRevoked + } + + hardwareUUID := req.HardwareUUID + hostname := req.Hostname + platform := req.Platform + osVersion := req.OSVersion + agentVersion := req.AgentVersion + + device.HardwareUUID = &hardwareUUID + device.SerialNumber = req.SerialNumber + device.Hostname = &hostname + device.Platform = &platform + device.OSVersion = &osVersion + device.AgentVersion = &agentVersion + + switch device.State { + case coredata.DeviceStatePending: + if err := device.Activate(ctx, conn, scope); err != nil { + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return ErrDeviceHardwareConflict + } + + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrDeviceRevoked + } + + return fmt.Errorf("cannot activate device: %w", err) + } + case coredata.DeviceStateActive: + if err := device.UpdateHeartbeat(ctx, conn, scope); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrDeviceRevoked + } + + return fmt.Errorf("cannot update device heartbeat: %w", err) + } + default: + return ErrDeviceRevoked + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return device, nil +} + +// RecordPostures appends posture results for a device. +func (s *Service) RecordPostures( + ctx context.Context, + scope coredata.Scoper, + deviceID gid.GID, + results []RecordPostureResult, +) error { + if len(results) == 0 { + return nil + } + + now := time.Now() + + return s.pg.WithTx( + ctx, + func(ctx context.Context, conn pg.Tx) error { + device := &coredata.Device{} + if err := device.LoadByIDForUpdate(ctx, conn, scope, deviceID); err != nil { + return fmt.Errorf("cannot load device: %w", err) + } + + if device.State != coredata.DeviceStateActive { + return ErrDeviceRevoked + } + + for _, r := range results { + posture := coredata.DevicePosture{ + ID: gid.New(device.OrganizationID.TenantID(), coredata.DevicePostureEntityType), + OrganizationID: device.OrganizationID, + DeviceID: device.ID, + CheckKey: r.CheckKey, + Status: r.Status, + Evidence: r.Evidence, + ObservedAt: r.ObservedAt, + CreatedAt: now, + } + if err := posture.Insert(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot insert device posture: %w", err) + } + } + + return nil + }, + ) +} + +// UnenrollDevice revokes the device. The agent invokes this from its +// uninstaller before wiping its local API key. +func (s *Service) UnenrollDevice( + ctx context.Context, + scope coredata.Scoper, + deviceID gid.GID, +) error { + return s.pg.WithTx( + ctx, + func(ctx context.Context, conn pg.Tx) error { + device := &coredata.Device{} + if err := device.LoadByID(ctx, conn, scope, deviceID); err != nil { + return fmt.Errorf("cannot load device: %w", err) + } + + if err := device.Revoke(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot revoke device: %w", err) + } + + return nil + }, + ) +}