Merge branch 'NM-356-apikeys' of https://github.com/gravitl/netmaker into NM-356-apikeys

This commit is contained in:
Abhishek Kondur
2026-10-07 13:35:56 +04:00
9 changed files with 384 additions and 105 deletions
+11 -5
View File
@@ -223,6 +223,11 @@ func applyDeviceNetworkHostState(ctx context.Context, host *schema.Host, network
if node, err := getHostNodeOnNetwork(ctx, host, network.Name); err == nil {
dn.Joined = true
dn.Connected = node.Connected
violations, _ := CheckPostureViolationsForHost(ctx, host, nil, schema.NetworkID(network.Name), true)
if len(violations) > 0 {
dn.Status = models.DeviceNetworkStatusBlocked
return
}
if node.Connected {
dn.Status = models.DeviceNetworkStatusJoined
} else {
@@ -284,6 +289,12 @@ func JoinDeviceNetwork(ctx context.Context, user *schema.User, host *schema.Host
if err := network.Get(ctx); err != nil {
return empty, fmt.Errorf("network not found: %w", err)
}
violations, _ := CheckPostureViolationsForHost(ctx, host, nil, schema.NetworkID(networkID), true)
if len(violations) > 0 {
return empty, errors.New("access blocked: this device doesn't meet security requirements")
}
if DoesHostExistInTheNetworkAlready(host, network) {
// Node may already exist but be disconnected after JIT expiry — reconnect.
if err := reconnectUserDeviceNode(ctx, host, networkID); err != nil {
@@ -292,11 +303,6 @@ func JoinDeviceNetwork(ctx context.Context, user *schema.User, host *schema.Host
return models.DeviceJoinResult{Status: models.DeviceJoinStatusJoined}, nil
}
violations, _ := CheckPostureViolationsForHost(ctx, host, nil, schema.NetworkID(networkID), true)
if len(violations) > 0 {
return empty, errors.New("access blocked: this device doesn't meet security requirements")
}
if deviceJoinRequiresApproval(ctx, *network, user) {
p := &schema.PendingHost{HostID: host.ID.String(), Network: networkID}
if err := p.CheckIfPendingHostExists(ctx); err == nil {
+76
View File
@@ -637,3 +637,79 @@ func TestRegisterDevice_rejectsCrossTenantHost(t *testing.T) {
require.Error(t, err)
assert.Equal(t, "host already registered to another tenant", err.Error())
}
func TestJoinDeviceNetworkBlocksReconnectOnPostureViolation(t *testing.T) {
ctx := scopedTestContext(t)
netName := "posture-reconnect-" + uuid.NewString()[:8]
username := "posture-reconnect-user-" + uuid.NewString()[:8]
require.NoError(t, CreateNetwork(ctx, &schema.Network{
TenantID: scope.ID(ctx),
Name: netName,
AddressRange: "10.91.0.0/24",
AutoJoin: true,
}))
t.Cleanup(func() { _ = (&schema.Network{Name: netName}).Delete(ctx) })
net := &schema.Network{Name: netName}
require.NoError(t, net.Get(ctx))
user := &schema.User{Username: username, PlatformRoleID: schema.PlatformUser}
require.NoError(t, user.Create(ctx))
t.Cleanup(func() { _ = user.Delete(ctx) })
hostID := uuid.New()
host := &schema.Host{
ID: hostID,
Name: "posture-reconnect-host",
OS: "linux",
Version: "dev",
HostPass: "test-pass",
TrafficKeyPublic: []byte{1, 2, 3},
OwnerUsername: username,
}
require.NoError(t, host.Create(ctx))
t.Cleanup(func() { _ = host.Delete(ctx) })
nodeID := uuid.NewString()
node := &schema.Node{
ID: nodeID,
TenantID: scope.ID(ctx),
HostID: hostID.String(),
NetworkID: net.ID,
Address: "10.91.0.10",
Connected: false,
Status: schema.Disconnected,
}
require.NoError(t, node.Create(ctx))
t.Cleanup(func() { _ = node.Delete(ctx) })
origJITAccess := CheckJITAccess
t.Cleanup(func() { CheckJITAccess = origJITAccess })
CheckJITAccess = func(context.Context, string, string) (bool, *schema.JITGrant, error) {
return true, nil, nil
}
origPosture := CheckPostureViolationsForHost
t.Cleanup(func() { CheckPostureViolationsForHost = origPosture })
CheckPostureViolationsForHost = func(context.Context, *schema.Host, map[models.TagID]struct{}, schema.NetworkID, bool) ([]models.Violation, schema.Severity) {
return []models.Violation{{CheckID: "os"}}, schema.SeverityHigh
}
joined := false
origJoin := JoinHostToNetworks
t.Cleanup(func() { JoinHostToNetworks = origJoin })
JoinHostToNetworks = func(context.Context, models.EnrollmentKey, *schema.Host, string) {
joined = true
}
_, err := JoinDeviceNetwork(ctx, user, host, netName)
require.Error(t, err)
assert.Equal(t, "access blocked: this device doesn't meet security requirements", err.Error())
assert.False(t, joined, "must not create a new join when posture fails on reconnect")
// Existing node must stay disconnected.
check := &schema.Node{ID: nodeID}
require.NoError(t, check.Get(ctx))
assert.False(t, check.Connected)
}
+4 -1
View File
@@ -93,7 +93,10 @@ func CreateFallbackNameserver(network *schema.Network) error {
"2001:4860:4860::8888",
"2001:4860:4860::8844",
},
Tags: map[string]interface{}{
Tags: map[string]any{
"*": "",
},
Users: map[string]any{
"*": "",
},
Status: true,
+28
View File
@@ -22,6 +22,34 @@ func IsUserOwnedHost(h *schema.Host) bool {
return h != nil && h.OwnerUsername != ""
}
// ListPostureViolatedUserOwnedDevices returns host-backed user devices on the
// network that have a non-unknown posture severity (for Non-compliant Users).
func ListPostureViolatedUserOwnedDevices(ctx context.Context, networkName string) ([]models.Node, error) {
network := &schema.Network{Name: networkName}
if err := network.Get(ctx); err != nil {
return nil, err
}
_nodes, err := (&schema.Node{}).ListAll(
ctx,
dbtypes.WithPreloads("Host"),
dbtypes.WithFilter("network_id", network.ID),
dbtypes.WithNotFilter("posture_check_severity", schema.SeverityUnknown),
)
if err != nil {
return nil, err
}
out := make([]models.Node, 0)
for i := range _nodes {
_nodes[i].Network = network
node := ConvertSchemaNodeToModelsNodeWithContext(ctx, &_nodes[i])
if node == nil || !IsUserOwnedDevice(node) {
continue
}
out = append(out, *node)
}
return out, nil
}
// skipPeerUpdateAclCalc reports whether GetPeerUpdateForHost should skip
// FwUpdate ACL calculation. User policies are unidirectional to servers and
// are emitted on the server host's peer update instead.
+31 -23
View File
@@ -21,29 +21,29 @@ type ApiNodeStatus struct {
// ApiNode is a stripped down Node DTO that exposes only required fields to external systems
type ApiNode struct {
ID string `json:"id,omitempty" validate:"required,min=5,id_unique"`
TenantID string `json:"tenant_id"`
HostID string `json:"hostid,omitempty" validate:"required,min=5,id_unique"`
Address string `json:"address" validate:"omitempty,cidrv4"`
Address6 string `json:"address6" validate:"omitempty,cidrv6"`
LocalAddress string `json:"localaddress" validate:"omitempty,cidr"`
AllowedIPs []string `json:"allowedips"`
LastModified int64 `json:"lastmodified" swaggertype:"primitive,integer" format:"int64"`
ExpirationDateTime int64 `json:"expdatetime" swaggertype:"primitive,integer" format:"int64"`
LastCheckIn int64 `json:"lastcheckin" swaggertype:"primitive,integer" format:"int64"`
LastPeerUpdate int64 `json:"lastpeerupdate" swaggertype:"primitive,integer" format:"int64"`
Network string `json:"network"`
NetworkRange string `json:"networkrange"`
NetworkRange6 string `json:"networkrange6"`
IsRelayed bool `json:"isrelayed"`
IsRelay bool `json:"isrelay"`
IsGw bool `json:"is_gw"`
IsAutoRelay bool `json:"is_auto_relay"`
AutoRelayedPeers map[string]string `json:"auto_relayed_peers"`
AutoAssignGateway bool `json:"auto_assign_gw"`
TcpProxyEnabled bool `json:"tcp_proxy_enabled"`
TcpProxyListenPort int `json:"tcp_proxy_listen_port"`
TcpProxyTLSMode string `json:"tcp_proxy_tls_mode"`
ID string `json:"id,omitempty" validate:"required,min=5,id_unique"`
TenantID string `json:"tenant_id"`
HostID string `json:"hostid,omitempty" validate:"required,min=5,id_unique"`
Address string `json:"address" validate:"omitempty,cidrv4"`
Address6 string `json:"address6" validate:"omitempty,cidrv6"`
LocalAddress string `json:"localaddress" validate:"omitempty,cidr"`
AllowedIPs []string `json:"allowedips"`
LastModified int64 `json:"lastmodified" swaggertype:"primitive,integer" format:"int64"`
ExpirationDateTime int64 `json:"expdatetime" swaggertype:"primitive,integer" format:"int64"`
LastCheckIn int64 `json:"lastcheckin" swaggertype:"primitive,integer" format:"int64"`
LastPeerUpdate int64 `json:"lastpeerupdate" swaggertype:"primitive,integer" format:"int64"`
Network string `json:"network"`
NetworkRange string `json:"networkrange"`
NetworkRange6 string `json:"networkrange6"`
IsRelayed bool `json:"isrelayed"`
IsRelay bool `json:"isrelay"`
IsGw bool `json:"is_gw"`
IsAutoRelay bool `json:"is_auto_relay"`
AutoRelayedPeers map[string]string `json:"auto_relayed_peers"`
AutoAssignGateway bool `json:"auto_assign_gw"`
TcpProxyEnabled bool `json:"tcp_proxy_enabled"`
TcpProxyListenPort int `json:"tcp_proxy_listen_port"`
TcpProxyTLSMode string `json:"tcp_proxy_tls_mode"`
TcpProxyListenAddr string `json:"tcp_proxy_listen_addr,omitempty"`
TcpProxyPublicHostname string `json:"tcp_proxy_public_hostname,omitempty"`
UseTcpUplink bool `json:"use_tcp_uplink"`
@@ -285,6 +285,14 @@ func (nm *Node) ConvertToAPINode() *ApiNode {
if apiNode.StaticNode.OwnerID == "" {
apiNode.StaticNode.OwnerID = nm.OwnerID
}
// Non-compliant Users UI reads violations from static_node (ExtClient shape).
if len(apiNode.StaticNode.PostureChecksViolations) == 0 && len(nm.PostureChecksViolations) > 0 {
apiNode.StaticNode.PostureChecksViolations = nm.PostureChecksViolations
apiNode.StaticNode.PostureCheckVolationSeverityLevel = nm.PostureCheckViolationSeverityLevel
}
if apiNode.StaticNode.LastEvaluatedAt.IsZero() && !nm.LastEvaluatedAt.IsZero() {
apiNode.StaticNode.LastEvaluatedAt = nm.LastEvaluatedAt
}
}
return &apiNode
}
+14
View File
@@ -31,6 +31,14 @@ func TestConvertToAPINode_marksUserOwnedHostAsUserNode(t *testing.T) {
Status: schema.OnlineSt,
},
Status: schema.OnlineSt,
PostureChecksViolations: []Violation{{
CheckID: "loc",
Name: "loc",
Attribute: "client_location",
Message: "client location not allowed",
Severity: schema.SeverityMedium,
}},
PostureCheckViolationSeverityLevel: schema.SeverityMedium,
}
api := nm.ConvertToAPINode()
@@ -46,6 +54,12 @@ func TestConvertToAPINode_marksUserOwnedHostAsUserNode(t *testing.T) {
if api.StaticNode.DeviceID != hostID.String() {
t.Fatalf("expected device_id %s, got %q", hostID, api.StaticNode.DeviceID)
}
if len(api.StaticNode.PostureChecksViolations) != 1 {
t.Fatalf("expected posture violations on static_node for UI, got %d", len(api.StaticNode.PostureChecksViolations))
}
if api.StaticNode.PostureCheckVolationSeverityLevel != schema.SeverityMedium {
t.Fatalf("expected medium severity on static_node, got %v", api.StaticNode.PostureCheckVolationSeverityLevel)
}
}
func TestConvertToAPINode_legacyExtClientUnchanged(t *testing.T) {
+19 -3
View File
@@ -369,6 +369,7 @@ func listPostureCheckViolatedNodes(w http.ResponseWriter, r *http.Request) {
listViolatedusers := r.URL.Query().Get("users") == "true"
violatedNodes := []models.Node{}
if listViolatedusers {
// Legacy RAC ExtClients.
extclients, err := logic.GetNetworkExtClients(r.Context(), networkName)
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, logic.BadReq))
@@ -381,6 +382,14 @@ func listPostureCheckViolatedNodes(w http.ResponseWriter, r *http.Request) {
}
}
}
// Host-backed user devices (desktop) belong under Non-compliant Users,
// not Non-compliant Nodes.
userDevices, err := logic.ListPostureViolatedUserOwnedDevices(r.Context(), networkName)
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, logic.Internal))
return
}
violatedNodes = append(violatedNodes, userDevices...)
} else {
network := &schema.Network{
Name: networkName,
@@ -409,9 +418,16 @@ func listPostureCheckViolatedNodes(w http.ResponseWriter, r *http.Request) {
return
}
for _, _node := range _nodes {
_node.Network = network
node := logic.ConvertSchemaNodeToModelsNode(&_node)
for i := range _nodes {
_nodes[i].Network = network
node := logic.ConvertSchemaNodeToModelsNodeWithContext(r.Context(), &_nodes[i])
if node == nil {
continue
}
// User-registered hosts are listed under users=true.
if logic.IsUserOwnedDevice(node) {
continue
}
violatedNodes = append(violatedNodes, *node)
}
}
+91 -73
View File
@@ -128,7 +128,7 @@ func RunPostureChecksForTenant(ctx context.Context) error {
if nodeI.IsStatic && !nodeI.IsUserNode {
continue
}
deviceInfo := logic.GetPostureCheckDeviceInfoByNode(ctx, &nodeI)
deviceInfo := GetPostureCheckDeviceInfoByNode(ctx, &nodeI)
var postureChecksViolations []models.Violation
var postureCheckVolationSeverityLevel schema.Severity
if noChecks {
@@ -245,18 +245,23 @@ func CheckPostureViolationsForHost(
network schema.NetworkID,
skipAutoUpdate bool,
) ([]models.Violation, schema.Severity) {
return CheckPostureViolations(ctx, GetPostureCheckDeviceInfoForHost(host, tags, skipAutoUpdate, true), network)
return CheckPostureViolations(ctx, GetPostureCheckDeviceInfoForHost(ctx, host, tags, network, skipAutoUpdate, true), network)
}
func GetPostureCheckDeviceInfoForHost(
ctx context.Context,
host *schema.Host,
tags map[models.TagID]struct{},
network schema.NetworkID,
skipAutoUpdate bool,
refreshIntegration bool,
) models.PostureCheckDeviceInfo {
if host == nil {
return models.PostureCheckDeviceInfo{}
}
if ctx == nil {
ctx = db.WithContext(context.TODO())
}
deviceInfo := models.PostureCheckDeviceInfo{
ClientLocation: host.CountryCode,
ClientVersion: host.Version,
@@ -269,12 +274,12 @@ func GetPostureCheckDeviceInfoForHost(
Tags: tags,
HostID: host.ID.String(),
}
ctx := db.WithContext(context.TODO())
if refreshIntegration {
_ = mdmpkg.RefreshHostMDMState(ctx, *host)
_ = edrpkg.RefreshHostEDRState(ctx, *host)
}
attachIntegrationStates(ctx, host.ID.String(), &deviceInfo)
attachOwnerUserGroups(ctx, &deviceInfo, host.OwnerUsername, network)
return deviceInfo
}
@@ -313,57 +318,8 @@ func GetPostureCheckViolations(ctx context.Context, checks []schema.PostureCheck
if c.Attribute == schema.AutoUpdate && (d.IsUser || d.SkipAutoUpdate) {
continue
}
// Check if tags match
if !d.IsUser {
// Check if posture check has wildcard tag - applies to all devices
if _, hasWildcard := c.Tags["*"]; hasWildcard {
// Wildcard tag matches all devices, continue to evaluate the check
} else if (c.Attribute == schema.MDMCompliance || c.Attribute == schema.EDRCompliance) && len(c.Tags) == 0 {
// Legacy MDM/EDR checks saved before wildcard default; apply to all hosts.
} else if len(c.Tags) > 0 {
// Check has specific tags - device must have at least one matching tag
if len(d.Tags) == 0 {
// Device has no tags and check doesn't have wildcard, skip
continue
}
exists := false
for tagID := range c.Tags {
if _, ok := d.Tags[models.TagID(tagID)]; ok {
exists = true
break
}
}
if !exists {
continue
}
} else {
// Check has no tags configured, skip
continue
}
} else if d.IsUser {
// Check if posture check has wildcard user group - applies to all users
if _, hasWildcard := c.UserGroups["*"]; hasWildcard {
// Wildcard user group matches all users, continue to evaluate the check
} else if len(c.UserGroups) > 0 {
// Check has specific user groups - user must have at least one matching group
if len(d.UserGroups) == 0 {
// User has no groups and check doesn't have wildcard, skip
continue
}
exists := false
for userG := range c.UserGroups {
if _, ok := d.UserGroups[schema.UserGroupID(userG)]; ok {
exists = true
break
}
}
if !exists {
continue
}
} else {
// Check has no user groups configured, skip
continue
}
if !postureCheckAppliesToSubject(&c, d) {
continue
}
checksByAttribute[c.Attribute] = append(checksByAttribute[c.Attribute], c)
@@ -465,6 +421,75 @@ func GetPostureCheckViolations(ctx context.Context, checks []schema.PostureCheck
return violations, highest
}
// postureCheckAppliesToSubject reports whether check c is in scope for subject d.
// Host-backed subjects may match via Tags and/or UserGroups (OR). ExtClient RAC
// subjects (IsUser) only use the UserGroups path.
func postureCheckAppliesToSubject(c *schema.PostureCheck, d models.PostureCheckDeviceInfo) bool {
if c == nil {
return false
}
tagMatch := false
userMatch := false
if !d.IsUser {
if _, hasWildcard := c.Tags["*"]; hasWildcard {
// Tags "*" applies to every host-backed subject (infra and user devices).
tagMatch = true
} else if (c.Attribute == schema.MDMCompliance || c.Attribute == schema.EDRCompliance) && len(c.Tags) == 0 {
// Legacy MDM/EDR checks saved before wildcard default; apply to all hosts.
tagMatch = true
} else if len(c.Tags) > 0 {
for tagID := range c.Tags {
if tagID == "*" {
continue
}
if _, ok := d.Tags[models.TagID(tagID)]; ok {
tagMatch = true
break
}
}
}
}
if d.IsUser || d.Username != "" {
if _, hasWildcard := c.UserGroups["*"]; hasWildcard {
userMatch = true
} else if len(c.UserGroups) > 0 {
for userG := range c.UserGroups {
if _, ok := d.UserGroups[schema.UserGroupID(userG)]; ok {
userMatch = true
break
}
}
}
}
return tagMatch || userMatch
}
// attachOwnerUserGroups fills Username / UserGroups for a host-backed or
// ExtClient posture subject. Does not set IsUser — host-backed devices keep
// the host/device evaluation path (e.g. AutoUpdate).
func attachOwnerUserGroups(ctx context.Context, d *models.PostureCheckDeviceInfo, ownerUsername string, network schema.NetworkID) {
if d == nil || ownerUsername == "" {
return
}
d.Username = ownerUsername
if d.UserGroups == nil {
d.UserGroups = make(map[schema.UserGroupID]struct{})
}
user := &schema.User{Username: ownerUsername}
if err := user.GetWithMembership(ctx); err != nil || len(user.UserGroups.Data()) == 0 {
return
}
d.UserGroups = user.UserGroups.Data()
if _, ok := user.UserGroups.Data()[GetDefaultGlobalAdminGroupID()]; ok {
d.UserGroups[GetDefaultNetworkAdminGroupID(network)] = struct{}{}
} else if _, ok := user.UserGroups.Data()[GetDefaultGlobalUserGroupID()]; ok {
d.UserGroups[GetDefaultNetworkUserGroupID(network)] = struct{}{}
}
}
// GetPostureCheckDeviceInfoByNode retrieves PostureCheckDeviceInfo for a given node
func GetPostureCheckDeviceInfoByNode(ctx context.Context, node *models.Node) models.PostureCheckDeviceInfo {
var deviceInfo models.PostureCheckDeviceInfo
@@ -491,6 +516,11 @@ func GetPostureCheckDeviceInfoByNode(ctx context.Context, node *models.Node) mod
_ = mdmpkg.RefreshHostMDMState(ctx, *h)
_ = edrpkg.RefreshHostEDRState(ctx, *h)
attachIntegrationStates(ctx, h.ID.String(), &deviceInfo)
owner := h.OwnerUsername
if owner == "" {
owner = logic.NodeOwnerUsername(node)
}
attachOwnerUserGroups(ctx, &deviceInfo, owner, schema.NetworkID(node.Network))
} else if node.IsUserNode {
deviceInfo = models.PostureCheckDeviceInfo{
ClientLocation: node.StaticNode.Country,
@@ -505,22 +535,7 @@ func GetPostureCheckDeviceInfoByNode(ctx context.Context, node *models.Node) mod
ClientID: node.StaticNode.ClientID,
UserGroups: make(map[schema.UserGroupID]struct{}),
}
// get user groups
if node.StaticNode.OwnerID != "" {
user := &schema.User{Username: node.StaticNode.OwnerID}
err := user.GetWithMembership(ctx)
if err == nil && len(user.UserGroups.Data()) > 0 {
deviceInfo.UserGroups = user.UserGroups.Data()
if _, ok := user.UserGroups.Data()[GetDefaultGlobalAdminGroupID()]; ok {
deviceInfo.UserGroups[GetDefaultNetworkAdminGroupID(schema.NetworkID(node.Network))] = struct{}{}
} else if _, ok := user.UserGroups.Data()[GetDefaultGlobalUserGroupID()]; ok {
deviceInfo.UserGroups[GetDefaultNetworkUserGroupID(schema.NetworkID(node.Network))] = struct{}{}
}
}
}
attachOwnerUserGroups(ctx, &deviceInfo, node.StaticNode.OwnerID, schema.NetworkID(node.Network))
}
return deviceInfo
@@ -996,7 +1011,10 @@ func EmitNewPostureViolationEvents(ctx context.Context, oldVi, newVi []models.Vi
sourceID := d.HostID
sourceName := d.HostID
sourceType := schema.DeviceSub
if d.IsUser {
// ExtClient RAC (IsUser) and host-backed user devices (Username set) are
// USER subjects; infra hosts remain DEVICE.
isUserSubject := d.IsUser || d.Username != ""
if isUserSubject {
sourceType = schema.UserSub
sourceID = d.Username
sourceName = d.Username
@@ -1021,9 +1039,9 @@ func EmitNewPostureViolationEvents(ctx context.Context, oldVi, newVi []models.Vi
"check": v.Name,
"reason": v.Message,
"severity": v.Severity,
"is_user": d.IsUser,
"is_user": isUserSubject,
}
if d.IsUser {
if isUserSubject {
payload["username"] = d.Username
payload["client_id"] = d.ClientID
}
+110
View File
@@ -0,0 +1,110 @@
package logic
import (
"context"
"testing"
"github.com/gravitl/netmaker/logic"
"github.com/gravitl/netmaker/models"
"github.com/gravitl/netmaker/schema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/datatypes"
)
func TestPostureCheckAppliesToSubject_allResourcesAndAllUsers(t *testing.T) {
check := &schema.PostureCheck{
Status: true,
Attribute: schema.ClientLocation,
Tags: datatypes.JSONMap{"*": "*"},
UserGroups: datatypes.JSONMap{"*": "*"},
Values: datatypes.JSONSlice[string]{"AX"},
}
infra := models.PostureCheckDeviceInfo{ClientLocation: "US"}
assert.True(t, postureCheckAppliesToSubject(check, infra), "Tags=* covers infrastructure")
userHost := models.PostureCheckDeviceInfo{
ClientLocation: "US",
Username: "alice",
UserGroups: map[schema.UserGroupID]struct{}{"eng": {}},
}
assert.True(t, postureCheckAppliesToSubject(check, userHost), "UserGroups=* covers host-backed user devices")
extClient := models.PostureCheckDeviceInfo{
ClientLocation: "US",
IsUser: true,
Username: "bob",
UserGroups: map[schema.UserGroupID]struct{}{"eng": {}},
}
assert.True(t, postureCheckAppliesToSubject(check, extClient), "UserGroups=* covers ExtClient users")
}
func TestPostureCheckAppliesToSubject_tagsStarIncludesUserHosts(t *testing.T) {
tagsOnly := &schema.PostureCheck{
Status: true,
Attribute: schema.ClientLocation,
Tags: datatypes.JSONMap{"*": "*"},
Values: datatypes.JSONSlice[string]{"AX"},
}
infra := models.PostureCheckDeviceInfo{ClientLocation: "US"}
assert.True(t, postureCheckAppliesToSubject(tagsOnly, infra))
userHost := models.PostureCheckDeviceInfo{
ClientLocation: "US",
Username: "alice",
}
assert.True(t, postureCheckAppliesToSubject(tagsOnly, userHost),
"Tags=* covers host-backed user devices so All Resources checks still flag them")
}
func TestPostureCheckAppliesToSubject_userGroupsOnly(t *testing.T) {
usersOnly := &schema.PostureCheck{
Status: true,
Attribute: schema.ClientLocation,
UserGroups: datatypes.JSONMap{"*": "*"},
Values: datatypes.JSONSlice[string]{"AX"},
}
infra := models.PostureCheckDeviceInfo{ClientLocation: "US"}
assert.False(t, postureCheckAppliesToSubject(usersOnly, infra))
userHost := models.PostureCheckDeviceInfo{
ClientLocation: "US",
Username: "alice",
}
assert.True(t, postureCheckAppliesToSubject(usersOnly, userHost))
}
func TestGetPostureCheckViolations_flagsHostBackedUserViaUserGroups(t *testing.T) {
orig := logic.GetFeatureFlags
t.Cleanup(func() { logic.GetFeatureFlags = orig })
logic.GetFeatureFlags = func(context.Context) models.FeatureFlags {
return models.FeatureFlags{EnablePostureChecks: true}
}
checks := []schema.PostureCheck{{
ID: "loc",
Name: "loc",
Status: true,
Attribute: schema.ClientLocation,
Severity: schema.SeverityMedium,
Tags: datatypes.JSONMap{"*": "*"},
UserGroups: datatypes.JSONMap{"*": "*"},
Values: datatypes.JSONSlice[string]{"AX"},
}}
userHost := models.PostureCheckDeviceInfo{
ClientLocation: "US",
Username: "alice",
}
violations, sev := GetPostureCheckViolations(context.Background(), checks, userHost)
require.NotEmpty(t, violations)
assert.Equal(t, schema.SeverityMedium, sev)
infraClean := models.PostureCheckDeviceInfo{ClientLocation: "AX"}
violations, sev = GetPostureCheckViolations(context.Background(), checks, infraClean)
assert.Empty(t, violations)
assert.Equal(t, schema.SeverityUnknown, sev)
}