diff --git a/docs/Contributing/product-groups/orchestration/understanding-host-vitals.md b/docs/Contributing/product-groups/orchestration/understanding-host-vitals.md index 25449830a8..344c08e170 100644 --- a/docs/Contributing/product-groups/orchestration/understanding-host-vitals.md +++ b/docs/Contributing/product-groups/orchestration/understanding-host-vitals.md @@ -59,10 +59,14 @@ SELECT email FROM users - Platforms: darwin +- Discovery query: +```sql +SELECT 1 FROM osquery_registry WHERE active = true AND registry = 'table' AND name = 'app_sso_platform' +``` + - Query: ```sql -SELECT * FROM (SELECT common_name AS device_id FROM certificates WHERE issuer LIKE '/DC=net+DC=windows+CN=MS-Organization-Access+OU%' LIMIT 1) - CROSS JOIN (SELECT label as user_principal_name FROM keychain_items WHERE account = 'com.microsoft.workplacejoin.registeredUserPrincipalName' LIMIT 1); +SELECT * FROM app_sso_platform WHERE extension_identifier = 'com.microsoft.CompanyPortalMac.ssoextension' AND realm = 'KERBEROS.MICROSOFTONLINE.COM'; ``` ## disk_encryption_darwin diff --git a/orbit/changes/28621-orbit-table-for-entra-device-id b/orbit/changes/28621-orbit-table-for-entra-device-id new file mode 100644 index 0000000000..890523de23 --- /dev/null +++ b/orbit/changes/28621-orbit-table-for-entra-device-id @@ -0,0 +1 @@ +* Added `app_sso_platform` table to get Platform SSO extensions state information. diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index e821f1f79b..90d9e0da9c 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -1,5 +1,14 @@ +//go:debug x509negativeserial=1 package main +// Note about the above "go:debug x509negativeserial=1": +// https://pkg.go.dev/crypto/x509#ParseCertificate: +// "Before Go 1.23, ParseCertificate accepted certificates with negative serial numbers. +// This behavior can be restored by including "x509negativeserial=1" in the GODEBUG environment +// variable. +// Why do we need this? +// Certificates generated by the Platform SSO extesion of Microsoft Company Portal can have negative serial numbers. + import ( "bytes" "context" diff --git a/orbit/pkg/table/app_sso_platform/app_sso_platform_darwin.go b/orbit/pkg/table/app_sso_platform/app_sso_platform_darwin.go new file mode 100644 index 0000000000..774eef7e4b --- /dev/null +++ b/orbit/pkg/table/app_sso_platform/app_sso_platform_darwin.go @@ -0,0 +1,247 @@ +//go:build darwin +// +build darwin + +package app_sso_platform + +import ( + "context" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os/exec" + "regexp" + "strings" + + "github.com/fleetdm/fleet/v4/orbit/pkg/user" + "github.com/osquery/osquery-go/plugin/table" + "github.com/rs/zerolog/log" +) + +// Columns is the schema of the table. +func Columns() []table.ColumnDefinition { + return []table.ColumnDefinition{ + // Extension identifier of the Platform SSO extension (e.g. "com.microsoft.CompanyPortalMac.ssoextension"). + // Required column, currently supports setting this once per query. + table.TextColumn("extension_identifier"), + // Realm of the user that logged via Platform SSO (e.g. "KERBEROS.MICROSOFTONLINE.COM"). + // Required column, currently supports setting this once per query. + table.TextColumn("realm"), + // Device ID extracted from "Device Configuration" -> "deviceSigningCertificate" -> Subject -> CommonName. + table.TextColumn("device_id"), + // User principal name of the user that logged in via Platform SSO. + table.TextColumn("user_principal_name"), + } +} + +// Generate is called to return the results for the table at query time. +// +// Constraints for generating can be retrieved from the queryContext. +func Generate(ctx context.Context, queryContext table.QueryContext) ([]map[string]string, error) { + extensionIdentifierConstraints, ok := queryContext.Constraints["extension_identifier"] + if !ok || len(extensionIdentifierConstraints.Constraints) == 0 { + return nil, errors.New("missing extension_identifier") + } + + var expectedExtensionIdentifiers []string + for _, constraint := range extensionIdentifierConstraints.Constraints { + if constraint.Operator != table.OperatorEquals { + return nil, errors.New("only supported operator for 'extension_identifier' is '='") + } + if constraint.Expression == "" { + continue + } + expectedExtensionIdentifiers = append(expectedExtensionIdentifiers, constraint.Expression) + } + if len(expectedExtensionIdentifiers) == 0 { + return nil, errors.New("missing extension_identifier") + } else if len(expectedExtensionIdentifiers) > 1 { + return nil, errors.New("only one extension_identifier can be set") + } + + realmConstraints, ok := queryContext.Constraints["realm"] + if !ok || len(realmConstraints.Constraints) == 0 { + return nil, errors.New("missing realm") + } + + var expectedRealms []string + for _, constraint := range realmConstraints.Constraints { + if constraint.Operator != table.OperatorEquals { + return nil, errors.New("only supported operator for 'realm' is '='") + } + if constraint.Expression == "" { + continue + } + expectedRealms = append(expectedRealms, constraint.Expression) + } + if len(expectedRealms) == 0 { + return nil, errors.New("missing realm") + } else if len(expectedRealms) > 1 { + return nil, errors.New("only one realm can be set") + } + + loggedInUser, err := user.UserLoggedInViaGui() + if err != nil { + return nil, fmt.Errorf("failed to check user logged in: %w", err) + } + if loggedInUser == nil || *loggedInUser == "" { + // User is not logged in, nothing to do so we return no results. + return nil, nil + } + + output, err := executeAppSSOPlatform(*loggedInUser) + if err != nil { + return nil, fmt.Errorf("failed to execute app-sso platform: %w", err) + } + + appSSOPlatform, err := parseAppSSOPlatformCommandOutput(output, expectedExtensionIdentifiers[0], expectedRealms[0]) + if err != nil { + return nil, fmt.Errorf("failed to parse \"app-sso platform --state\" output: %w", err) + } + if appSSOPlatform == nil { + // Device not registered, nothing to do so we return no results. + return nil, nil + } + + return []map[string]string{{ + "extension_identifier": appSSOPlatform.extensionIdentifier, + "realm": appSSOPlatform.realm, + "device_id": appSSOPlatform.deviceID, + "user_principal_name": appSSOPlatform.userPrincipalName, + }}, nil +} + +func executeAppSSOPlatform(loggedInUser string) ([]byte, error) { + cmd := exec.Command("sh", "-c", fmt.Sprintf(`launchctl asuser $(id -u "%s") sudo -iu "%s" /usr/bin/app-sso platform --state`, loggedInUser, loggedInUser)) // #nosec G20: loggedInUser is not controlled by user. + return cmd.Output() +} + +var ( + // deviceRe extracts JSON after "Device Configuration:" and before "Login Configuration:" + deviceRe = regexp.MustCompile(`(?s)Device Configuration:\n\s(\{.*?\}|\(null\))\n\nLogin Configuration:`) + // userRe extracts JSON after "User Configuration:" and before "SSO Tokens:" (or end of string) + userRe = regexp.MustCompile(`(?s)User Configuration:\n\s(\{.*?\}|\(null\))\n\n`) +) + +// extractJSONSections finds JSON blocks for "Device Configuration" and "User Configuration". +func extractJSONSections(s []byte) (deviceConfig string, userConfig string, err error) { + deviceMatch := deviceRe.FindSubmatch(s) + userMatch := userRe.FindSubmatch(s) + + if len(deviceMatch) < 2 { + return "", "", errors.New("match for \"Device Configuration\" not found") + } + if len(userMatch) < 2 { + return "", "", errors.New("match for \"User Configuration\" JSON not found") + } + + return string(deviceMatch[1]), string(userMatch[1]), nil +} + +type appSSOPlatformData struct { + extensionIdentifier string + deviceID string + realm string + userPrincipalName string +} + +func parseAppSSOPlatformCommandOutput(output []byte, expectedExtensionIdentifier string, expectedRealm string) (*appSSOPlatformData, error) { + deviceConfigJSON, userConfigJSON, err := extractJSONSections(output) + if err != nil { + return nil, fmt.Errorf("could not extract JSON sections: %w", err) + } + if deviceConfigJSON == "(null)" { + log.Debug().Msg("device not registered") + return nil, nil + } + deviceConfig := struct { + DeviceSigningCertificate string `json:"deviceSigningCertificate"` + ExtensionIdentifier string `json:"extensionIdentifier"` + }{} + if err := json.Unmarshal([]byte(deviceConfigJSON), &deviceConfig); err != nil { + return nil, fmt.Errorf("could not unmarshal \"Device Configuration\" JSON: %w", err) + } + if expectedExtensionIdentifier != deviceConfig.ExtensionIdentifier { + log.Debug().Str("extensionIdentifier", deviceConfig.ExtensionIdentifier).Msg("device registered, but found unmatched extension") + return nil, nil + } + dsc, err := base64.RawURLEncoding.DecodeString(deviceConfig.DeviceSigningCertificate) + if err != nil { + return nil, fmt.Errorf("failed to decode \"deviceSigningCertificate\": %w", err) + } + deviceSigningCertificate, err := x509.ParseCertificate(dsc) + if err != nil { + return nil, fmt.Errorf("failed to parse \"deviceSigningCertificate\": %w", err) + } + if deviceSigningCertificate.Subject.CommonName == "" { + return nil, errors.New("empty subject common name in \"deviceSigningCertificate\"") + } + log.Debug().Str( + "\"Device Configuration\"", deviceSigningCertificate.Subject.CommonName, + ).Msg("found device ID") + userConfig := struct { + KerberosStatus []map[string]any `json:"kerberosStatus"` + }{} + if userConfigJSON == "(null)" { + log.Debug().Msg("user not registered") + return &appSSOPlatformData{ + extensionIdentifier: deviceConfig.ExtensionIdentifier, + deviceID: deviceSigningCertificate.Subject.CommonName, + realm: expectedRealm, + userPrincipalName: "", + }, nil + } + if err := json.Unmarshal([]byte(userConfigJSON), &userConfig); err != nil { + return nil, fmt.Errorf("could not unmarshal \"User Configuration\" JSON: %w", err) + } + if len(userConfig.KerberosStatus) == 0 { + return nil, errors.New("\"kerberosStatus\" has no entries") + } + realm_, ok := userConfig.KerberosStatus[0]["realm"] + if !ok { + return nil, errors.New("missing \"realm\" key in \"kerberosStatus\"") + } + realm, ok := realm_.(string) + if !ok { + return nil, fmt.Errorf("unexpected type for \"realm\" key in \"kerberosStatus\": %T", err) + } + upn_, ok := userConfig.KerberosStatus[0]["upn"] + if !ok { + return nil, errors.New("missing \"upn\" key in \"kerberosStatus\"") + } + upn, ok := upn_.(string) + if !ok { + return nil, fmt.Errorf("unexpected type for \"upn\" key in \"kerberosStatus\": %T", err) + } + if upn == "" { + return nil, errors.New("empty \"upn\" key in \"kerberosStatus\"") + } + if expectedRealm != realm { + log.Debug().Str("realm", realm).Msg("user registered, but found unmatched realm") + return &appSSOPlatformData{ + extensionIdentifier: deviceConfig.ExtensionIdentifier, + deviceID: deviceSigningCertificate.Subject.CommonName, + realm: expectedRealm, + userPrincipalName: "", + }, nil + } + suffix := fmt.Sprintf("@%s", realm) + upn = strings.TrimSuffix(upn, suffix) + upn = strings.ReplaceAll(upn, "\\@", "@") + log.Debug().Str( + "extension_identifier", deviceConfig.ExtensionIdentifier, + ).Str( + "device_id", deviceSigningCertificate.Subject.CommonName, + ).Str( + "realm", realm, + ).Str( + "user_principal_name", upn, + ).Msg("device and user found") + return &appSSOPlatformData{ + extensionIdentifier: deviceConfig.ExtensionIdentifier, + deviceID: deviceSigningCertificate.Subject.CommonName, + realm: realm, + userPrincipalName: upn, + }, nil +} diff --git a/orbit/pkg/table/app_sso_platform/app_sso_platform_darwin_test.go b/orbit/pkg/table/app_sso_platform/app_sso_platform_darwin_test.go new file mode 100644 index 0000000000..654c3723df --- /dev/null +++ b/orbit/pkg/table/app_sso_platform/app_sso_platform_darwin_test.go @@ -0,0 +1,248 @@ +//go:build darwin +// +build darwin + +package app_sso_platform + +import ( + "context" + _ "embed" + "testing" + + "github.com/osquery/osquery-go/plugin/table" + "github.com/stretchr/testify/require" +) + +var ( + //go:embed testdata/app_sso_platform_state_sample1.txt + sample1 string + + //go:embed testdata/app_sso_platform_state_sample2_user_null.txt + sample2 string + + //go:embed testdata/app_sso_platform_state_empty.txt + empty string +) + +func TestParseAppSSOPlatformCommandOutput(t *testing.T) { + // Match + data, err := parseAppSSOPlatformCommandOutput([]byte(sample1), "com.microsoft.CompanyPortalMac.ssoextension", "KERBEROS.MICROSOFTONLINE.COM") + require.NoError(t, err) + require.NotNil(t, data) + require.Equal(t, "34b1ba9a-3b2d-4c6c-ab4b-615f4b143eab", data.deviceID) + require.Equal(t, "com.microsoft.CompanyPortalMac.ssoextension", data.extensionIdentifier) + require.Equal(t, "KERBEROS.MICROSOFTONLINE.COM", data.realm) + require.Equal(t, "foobar@contoso.onmicrosoft.com", data.userPrincipalName) + + // Empty, Platform SSO not set yet. + data, err = parseAppSSOPlatformCommandOutput([]byte(empty), "com.microsoft.CompanyPortalMac.ssoextension", "KERBEROS.MICROSOFTONLINE.COM") + require.NoError(t, err) + require.Nil(t, data) + + // Platform SSO extension identifier does not match. + data, err = parseAppSSOPlatformCommandOutput([]byte(sample1), "com.microsoft.Other.other", "KERBEROS.MICROSOFTONLINE.COM") + require.NoError(t, err) + require.Nil(t, data) + + // Platform SSO extension identifier matches, but user realm doesn't match. + data, err = parseAppSSOPlatformCommandOutput([]byte(sample1), "com.microsoft.CompanyPortalMac.ssoextension", "FOOBAR.OTHER.COM") + require.NoError(t, err) + require.NotNil(t, data) + require.Equal(t, "34b1ba9a-3b2d-4c6c-ab4b-615f4b143eab", data.deviceID) + require.Equal(t, "com.microsoft.CompanyPortalMac.ssoextension", data.extensionIdentifier) + require.Equal(t, "FOOBAR.OTHER.COM", data.realm) + require.Equal(t, "", data.userPrincipalName) + + // None matches. + data, err = parseAppSSOPlatformCommandOutput([]byte(sample1), "com.microsoft.Other.other", "FOOBAR.OTHER.COM") + require.NoError(t, err) + require.Nil(t, data) + + // Platform SSO extension identifier matches, but user is not registered yet (null). + // Can happen if Platform SSO configuration profile was deployed and this is a workstation with two users, + // and one user registered but not the other one. + data, err = parseAppSSOPlatformCommandOutput([]byte(sample2), "com.microsoft.CompanyPortalMac.ssoextension", "KERBEROS.MICROSOFTONLINE.COM") + require.NoError(t, err) + require.NotNil(t, data) + require.Equal(t, "34b1ba9a-3b2d-4c6c-ab4b-615f4b143eab", data.deviceID) + require.Equal(t, "com.microsoft.CompanyPortalMac.ssoextension", data.extensionIdentifier) + require.Equal(t, "KERBEROS.MICROSOFTONLINE.COM", data.realm) + require.Equal(t, "", data.userPrincipalName) +} + +func TestGenerateErrors(t *testing.T) { + // Multiple extension_identifier values. + _, err := Generate(context.Background(), table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "extension_identifier": { + Constraints: []table.Constraint{ + { + Operator: table.OperatorEquals, + Expression: "extension_identifier_value1", + }, + { + Operator: table.OperatorEquals, + Expression: "extension_identifier_value2", + }, + }, + }, + "realm": { + Constraints: []table.Constraint{ + { + Operator: table.OperatorEquals, + Expression: "realm_value", + }, + }, + }, + }, + }) + require.Error(t, err) + + // Multiple realm values. + _, err = Generate(context.Background(), table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "extension_identifier": { + Constraints: []table.Constraint{ + { + Operator: table.OperatorEquals, + Expression: "extension_identifier_value", + }, + }, + }, + "realm": { + Constraints: []table.Constraint{ + { + Operator: table.OperatorEquals, + Expression: "realm_value1", + }, + { + Operator: table.OperatorEquals, + Expression: "realm_value2", + }, + }, + }, + }, + }) + require.Error(t, err) + + // Multiple extension_identifier value. + _, err = Generate(context.Background(), table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "realm": { + Constraints: []table.Constraint{ + { + Operator: table.OperatorEquals, + Expression: "realm_value", + }, + }, + }, + }, + }) + require.Error(t, err) + + // Missing realm value. + _, err = Generate(context.Background(), table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "extension_identifier": { + Constraints: []table.Constraint{ + { + Operator: table.OperatorEquals, + Expression: "extension_identifier_value", + }, + }, + }, + }, + }) + require.Error(t, err) + + // Not using equality on extension_identifier. + _, err = Generate(context.Background(), table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "extension_identifier": { + Constraints: []table.Constraint{ + { + Operator: table.OperatorLike, + Expression: "extension_identifier_value", + }, + }, + }, + "realm": { + Constraints: []table.Constraint{ + { + Operator: table.OperatorEquals, + Expression: "realm_value", + }, + }, + }, + }, + }) + require.Error(t, err) + + // Not using equality on realm. + _, err = Generate(context.Background(), table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "extension_identifier": { + Constraints: []table.Constraint{ + { + Operator: table.OperatorEquals, + Expression: "extension_identifier_value", + }, + }, + }, + "realm": { + Constraints: []table.Constraint{ + { + Operator: table.OperatorLike, + Expression: "realm_value", + }, + }, + }, + }, + }) + require.Error(t, err) + + // Empty extension_identifier. + _, err = Generate(context.Background(), table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "extension_identifier": { + Constraints: []table.Constraint{ + { + Operator: table.OperatorEquals, + Expression: "", + }, + }, + }, + "realm": { + Constraints: []table.Constraint{ + { + Operator: table.OperatorEquals, + Expression: "realm_value", + }, + }, + }, + }, + }) + require.Error(t, err) + + // Empty realm. + _, err = Generate(context.Background(), table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "extension_identifier": { + Constraints: []table.Constraint{ + { + Operator: table.OperatorEquals, + Expression: "extension_identifier_value", + }, + }, + }, + "realm": { + Constraints: []table.Constraint{ + { + Operator: table.OperatorEquals, + Expression: "", + }, + }, + }, + }, + }) + require.Error(t, err) +} diff --git a/orbit/pkg/table/app_sso_platform/testdata/app_sso_platform_state_empty.txt b/orbit/pkg/table/app_sso_platform/testdata/app_sso_platform_state_empty.txt new file mode 100644 index 0000000000..576b916aab --- /dev/null +++ b/orbit/pkg/table/app_sso_platform/testdata/app_sso_platform_state_empty.txt @@ -0,0 +1,11 @@ +Time: 2025-06-18 20:42:20 +0000 + +Device Configuration: + (null) + +Login Configuration: + (null) + +User Configuration: + (null) + diff --git a/orbit/pkg/table/app_sso_platform/testdata/app_sso_platform_state_sample1.txt b/orbit/pkg/table/app_sso_platform/testdata/app_sso_platform_state_sample1.txt new file mode 100644 index 0000000000..c306336f43 --- /dev/null +++ b/orbit/pkg/table/app_sso_platform/testdata/app_sso_platform_state_sample1.txt @@ -0,0 +1,175 @@ +Time: 2025-06-18 20:23:40 +0000 + +Device Configuration: + { + "_deviceEncryptionKeyData" : "", + "_deviceSigningKeyData" : "", + "allowDeviceIdentifiersInAttestation" : false, + "authGracePeriodStart" : "2025-06-18T13:10:46Z", + "authorizationEnabled" : false, + "created" : "2025-06-18T20:23:40Z", + "createUsersEnabled" : false, + "deviceSigningCertificate" : "MIIDNzCCAh-gAwIBAgIQcdJMRMM2o4xHxrDE4zP1tzANBgkqhkiG9w0BAQsFADB4MXYwEQYKCZImiZPyLGQBGRYDbmV0MBUGCgmSJomT8ixkARkWB3dpbmRvd3MwHQYDVQQDExZNUy1Pcmdhbml6YXRpb24tQWNjZXNzMCsGA1UECxMkODJkYmFjYTQtM2U4MS00NmNhLTljNzMtMDk1MGMxZWFjYTk3MB4XDTI1MDYxODEyNDIyNloXDTM1MDYxODEzMTIyNlowLzEtMCsGA1UEAxMkMzRiMWJhOWEtM2IyZC00YzZjLWFiNGItNjE1ZjRiMTQzZWFiMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7CIbedoCo3XPErh3BOXJBajYifimV1fEt9aSWEYnrnKW5nB6Ynr38taXo8ZeiRB2uN7fJrqtqo-Vd2nY8G8VNqOB0DCBzTAMBgNVHRMBAf8EAjAAMBYGA1UdJQEB_wQMMAoGCCsGAQUFBwMCMA4GA1UdDwEB_wQEAwIHgDAiBgsqhkiG9xQBBYIcAgQTBIEQmrqxNC07bEyrS2FfSxQ-qzAiBgsqhkiG9xQBBYIcAwQTBIEQMnxB-ihBNUyHp4spMP1LXTAiBgsqhkiG9xQBBYIcBQQTBIEQ3hS3ryz08kGAL2WykR3TRjAUBgsqhkiG9xQBBYIcCAQFBIECTkEwEwYLKoZIhvcUAQWCHAcEBASBATEwDQYJKoZIhvcNAQELBQADggEBAD3BG_COLS5iniJ0-pir1snO5W7JHVKKP97GLEtAbvvpJ1eG7lI5zQKZGqGyxHquq1p8rnCSf-cxIgl-OufMC6yRvO0M4yztd1X6DG4LZUZlV39LW8SM-Ag_08CBpYRAZuYPks5DY-VgF8zXEfTfvrJIjGcd3Vhd3nH0wzyI-OcC12qV7dC2PKP5B3ZCLmUUOgQ0giCBXO1LhW397HOewrnk-0B5n25KBLK7WYBG5qx9eTm2U7mMMHs93--VsIDjDkgBYd5EJPHylQDG_pkCdYDTDOfL9wWrAUiOu3pKi4yAuYNKqC2h06sg6xDomoVuDoGWhLdzoTpcmdzBYjPAutg", + "encryptionAlgorithm" : "ECDHE-A256GCM", + "extensionIdentifier" : "com.microsoft.CompanyPortalMac.ssoextension", + "fileVaultPolicy" : "None (0)", + "lastEncryptionKeyChange" : "2025-06-18T13:10:46Z", + "loginFrequency" : 64800, + "loginPolicy" : "None (0)", + "loginType" : "POLoginTypeUserSecureEnclaveKey (2)", + "newUserAuthorizationMode" : "None", + "offlineGracePeriod" : "0 hours", + "pendingEncryptionAlgorithm" : "none", + "pendingSigningAlgorithm" : "none", + "protocolVersion" : 1, + "registrationCompleted" : true, + "requireAuthGracePeriod" : "0 hours", + "sdkVersionString" : 0, + "sharedDeviceKeys" : true, + "signingAlgorithm" : "ES256", + "tokenToUserMapping" : { + "AccountName" : "preferred_username", + "FullName" : "name" + }, + "unlockPolicy" : "None (0)", + "userAuthorizationMode" : "None", + "version" : 1 +} + +Login Configuration: + { + "accountDisplayName" : "Microsoft Entra", + "additionalScopes" : "aza urn:aad:tb:update:prt/.default profile offline_access openid", + "audience" : "login.microsoftonline.com", + "clientID" : "", + "created" : "2025-06-18T20:23:40Z", + "customAssertionRequestHeaderClaims" : { + "typ" : "JWT", + "use" : "ngc" + }, + "customKeyExchangeRequestBodyClaims" : { + "aud" : "https://login.microsoftonline.com//getkeydata" + }, + "customKeyExchangeRequestHeaderClaims" : { + "typ" : "JWT" + }, + "customKeyExchangeRequestValues" : { + "client_info" : "1", + "prt_protocol_version" : "4.0", + "tgt" : "true", + "x-client-brkrver" : "3.6.4", + "x-client-OS" : "15.5.0", + "x-client-SKU" : "MSAL.OSX", + "x-client-Ver" : "1.8.1" + }, + "customKeyRequestBodyClaims" : { + "aud" : "https://login.microsoftonline.com//getkeydata" + }, + "customKeyRequestHeaderClaims" : { + "typ" : "JWT" + }, + "customKeyRequestValues" : { + "client_info" : "1", + "prt_protocol_version" : "4.0", + "tgt" : "true", + "x-client-brkrver" : "3.6.4", + "x-client-OS" : "15.5.0", + "x-client-SKU" : "MSAL.OSX", + "x-client-Ver" : "1.8.1" + }, + "customLoginRequestHeaderClaims" : { + "typ" : "JWT" + }, + "customLoginRequestValues" : { + "client_info" : "1", + "prt_protocol_version" : "4.0", + "tgt" : "true", + "x-client-brkrver" : "3.6.4", + "x-client-OS" : "15.5.0", + "x-client-SKU" : "MSAL.OSX", + "x-client-Ver" : "1.8.1" + }, + "customNonceRequestValues" : { + "client_info" : "1", + "prt_protocol_version" : "4.0", + "tgt" : "true", + "x-client-brkrver" : "3.6.4", + "x-client-OS" : "15.5.0", + "x-client-SKU" : "MSAL.OSX", + "x-client-Ver" : "1.8.1" + }, + "customRequestJWTParameterName" : "request", + "deviceContext" : "", + "federationMexURLKeypath" : "federation_metadata_url", + "federationPredicate" : "account_type = 'Federated'", + "federationRequestURN" : "urn:federation:MicrosoftOnline", + "federationType" : 2, + "federationUserPreauthenticationURL" : "https://login.windows.net/common/UserRealm?api-version=1.0&checkForMicrosoftAccount=false", + "includePreviousRefreshTokenInLoginRequest" : true, + "invalidCredentialPredicate" : "error = 'invalid_grant' AND suberror != 'device_authentication_failed'", + "issuer" : "https://login.microsoftonline.com//v2.0", + "jwksEndpointURL" : "https://login.microsoftonline.com//discovery/v2.0/keys", + "kerberosTicketMappings" : [ + { + "clientNameKeyName" : "cn", + "encryptionKeyTypeKeyName" : "keyType", + "messageBufferKeyName" : "messageBuffer", + "realmKeyName" : "realm", + "serviceNameKeyName" : "sn", + "sessionKeyKeyName" : "clientKey", + "ticketKeyPath" : "tgt_ad" + }, + { + "clientNameKeyName" : "cn", + "encryptionKeyTypeKeyName" : "keyType", + "messageBufferKeyName" : "messageBuffer", + "realmKeyName" : "realm", + "serviceNameKeyName" : "sn", + "sessionKeyKeyName" : "clientKey", + "ticketKeyPath" : "tgt_cloud" + } + ], + "keyEndpointURL" : "https://login.microsoftonline.com//getkeydata", + "loginRequestEncryptionAlgorithm" : "ECDHE-A256GCM", + "nonceResponseKeypath" : "Nonce", + "previousRefreshTokenClaimName" : "previous_refresh_token", + "serverNonceClaimName" : "request_nonce", + "tokenEndpointURL" : "https://login.microsoftonline.com//oauth2/v2.0/token", + "uniqueIdentifierClaimName" : "oid", + "userSEPKeyBiometricPolicy" : "None (0)" +} + +User Configuration: + { + "_sepKeyData" : "EOKfOXCpi9nQHsrm6EZtXXUiMJvabJeFmaiNoBHyuZE=", + "created" : "2025-06-18T20:23:40Z", + "kerberosStatus" : [ + { + "cacheName" : "C0379BAF-5FE3-4002-9B66-F95205EE7BF6", + "exchangeRequired" : false, + "failedToConnect" : false, + "importSuccessful" : true, + "realm" : "KERBEROS.MICROSOFTONLINE.COM", + "ticketKeyPath" : "tgt_cloud", + "upn" : "foobar\\@contoso.onmicrosoft.com@KERBEROS.MICROSOFTONLINE.COM" + } + ], + "lastLoginDate" : "2025-06-18T20:22:55Z", + "loginType" : "POLoginTypeUserSecureEnclaveKey (2)", + "pendingSigningAlgorithm" : "none", + "signingAlgorithm" : "ES256", + "state" : "POUserStateNormal (0)", + "uniqueIdentifier" : "", + "userLoginConfiguration" : { + "created" : "2025-06-18T20:23:40Z", + "loginUserName" : "f***@contoso.onmicrosoft.com" + }, + "version" : 1 +} + +SSO Tokens: +Received: +2025-06-18T20:22:55Z +Expiration: +2025-07-02T20:22:54Z (Not Expired) + diff --git a/orbit/pkg/table/app_sso_platform/testdata/app_sso_platform_state_sample2_user_null.txt b/orbit/pkg/table/app_sso_platform/testdata/app_sso_platform_state_sample2_user_null.txt new file mode 100644 index 0000000000..3fe93b20d7 --- /dev/null +++ b/orbit/pkg/table/app_sso_platform/testdata/app_sso_platform_state_sample2_user_null.txt @@ -0,0 +1,144 @@ +Time: 2025-06-18 20:23:40 +0000 + +Device Configuration: + { + "_deviceEncryptionKeyData" : "", + "_deviceSigningKeyData" : "", + "allowDeviceIdentifiersInAttestation" : false, + "authGracePeriodStart" : "2025-06-18T13:10:46Z", + "authorizationEnabled" : false, + "created" : "2025-06-18T20:23:40Z", + "createUsersEnabled" : false, + "deviceSigningCertificate" : "MIIDNzCCAh-gAwIBAgIQcdJMRMM2o4xHxrDE4zP1tzANBgkqhkiG9w0BAQsFADB4MXYwEQYKCZImiZPyLGQBGRYDbmV0MBUGCgmSJomT8ixkARkWB3dpbmRvd3MwHQYDVQQDExZNUy1Pcmdhbml6YXRpb24tQWNjZXNzMCsGA1UECxMkODJkYmFjYTQtM2U4MS00NmNhLTljNzMtMDk1MGMxZWFjYTk3MB4XDTI1MDYxODEyNDIyNloXDTM1MDYxODEzMTIyNlowLzEtMCsGA1UEAxMkMzRiMWJhOWEtM2IyZC00YzZjLWFiNGItNjE1ZjRiMTQzZWFiMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7CIbedoCo3XPErh3BOXJBajYifimV1fEt9aSWEYnrnKW5nB6Ynr38taXo8ZeiRB2uN7fJrqtqo-Vd2nY8G8VNqOB0DCBzTAMBgNVHRMBAf8EAjAAMBYGA1UdJQEB_wQMMAoGCCsGAQUFBwMCMA4GA1UdDwEB_wQEAwIHgDAiBgsqhkiG9xQBBYIcAgQTBIEQmrqxNC07bEyrS2FfSxQ-qzAiBgsqhkiG9xQBBYIcAwQTBIEQMnxB-ihBNUyHp4spMP1LXTAiBgsqhkiG9xQBBYIcBQQTBIEQ3hS3ryz08kGAL2WykR3TRjAUBgsqhkiG9xQBBYIcCAQFBIECTkEwEwYLKoZIhvcUAQWCHAcEBASBATEwDQYJKoZIhvcNAQELBQADggEBAD3BG_COLS5iniJ0-pir1snO5W7JHVKKP97GLEtAbvvpJ1eG7lI5zQKZGqGyxHquq1p8rnCSf-cxIgl-OufMC6yRvO0M4yztd1X6DG4LZUZlV39LW8SM-Ag_08CBpYRAZuYPks5DY-VgF8zXEfTfvrJIjGcd3Vhd3nH0wzyI-OcC12qV7dC2PKP5B3ZCLmUUOgQ0giCBXO1LhW397HOewrnk-0B5n25KBLK7WYBG5qx9eTm2U7mMMHs93--VsIDjDkgBYd5EJPHylQDG_pkCdYDTDOfL9wWrAUiOu3pKi4yAuYNKqC2h06sg6xDomoVuDoGWhLdzoTpcmdzBYjPAutg", + "encryptionAlgorithm" : "ECDHE-A256GCM", + "extensionIdentifier" : "com.microsoft.CompanyPortalMac.ssoextension", + "fileVaultPolicy" : "None (0)", + "lastEncryptionKeyChange" : "2025-06-18T13:10:46Z", + "loginFrequency" : 64800, + "loginPolicy" : "None (0)", + "loginType" : "POLoginTypeUserSecureEnclaveKey (2)", + "newUserAuthorizationMode" : "None", + "offlineGracePeriod" : "0 hours", + "pendingEncryptionAlgorithm" : "none", + "pendingSigningAlgorithm" : "none", + "protocolVersion" : 1, + "registrationCompleted" : true, + "requireAuthGracePeriod" : "0 hours", + "sdkVersionString" : 0, + "sharedDeviceKeys" : true, + "signingAlgorithm" : "ES256", + "tokenToUserMapping" : { + "AccountName" : "preferred_username", + "FullName" : "name" + }, + "unlockPolicy" : "None (0)", + "userAuthorizationMode" : "None", + "version" : 1 +} + +Login Configuration: + { + "accountDisplayName" : "Microsoft Entra", + "additionalScopes" : "aza urn:aad:tb:update:prt/.default profile offline_access openid", + "audience" : "login.microsoftonline.com", + "clientID" : "", + "created" : "2025-06-18T20:23:40Z", + "customAssertionRequestHeaderClaims" : { + "typ" : "JWT", + "use" : "ngc" + }, + "customKeyExchangeRequestBodyClaims" : { + "aud" : "https://login.microsoftonline.com//getkeydata" + }, + "customKeyExchangeRequestHeaderClaims" : { + "typ" : "JWT" + }, + "customKeyExchangeRequestValues" : { + "client_info" : "1", + "prt_protocol_version" : "4.0", + "tgt" : "true", + "x-client-brkrver" : "3.6.4", + "x-client-OS" : "15.5.0", + "x-client-SKU" : "MSAL.OSX", + "x-client-Ver" : "1.8.1" + }, + "customKeyRequestBodyClaims" : { + "aud" : "https://login.microsoftonline.com//getkeydata" + }, + "customKeyRequestHeaderClaims" : { + "typ" : "JWT" + }, + "customKeyRequestValues" : { + "client_info" : "1", + "prt_protocol_version" : "4.0", + "tgt" : "true", + "x-client-brkrver" : "3.6.4", + "x-client-OS" : "15.5.0", + "x-client-SKU" : "MSAL.OSX", + "x-client-Ver" : "1.8.1" + }, + "customLoginRequestHeaderClaims" : { + "typ" : "JWT" + }, + "customLoginRequestValues" : { + "client_info" : "1", + "prt_protocol_version" : "4.0", + "tgt" : "true", + "x-client-brkrver" : "3.6.4", + "x-client-OS" : "15.5.0", + "x-client-SKU" : "MSAL.OSX", + "x-client-Ver" : "1.8.1" + }, + "customNonceRequestValues" : { + "client_info" : "1", + "prt_protocol_version" : "4.0", + "tgt" : "true", + "x-client-brkrver" : "3.6.4", + "x-client-OS" : "15.5.0", + "x-client-SKU" : "MSAL.OSX", + "x-client-Ver" : "1.8.1" + }, + "customRequestJWTParameterName" : "request", + "deviceContext" : "", + "federationMexURLKeypath" : "federation_metadata_url", + "federationPredicate" : "account_type = 'Federated'", + "federationRequestURN" : "urn:federation:MicrosoftOnline", + "federationType" : 2, + "federationUserPreauthenticationURL" : "https://login.windows.net/common/UserRealm?api-version=1.0&checkForMicrosoftAccount=false", + "includePreviousRefreshTokenInLoginRequest" : true, + "invalidCredentialPredicate" : "error = 'invalid_grant' AND suberror != 'device_authentication_failed'", + "issuer" : "https://login.microsoftonline.com//v2.0", + "jwksEndpointURL" : "https://login.microsoftonline.com//discovery/v2.0/keys", + "kerberosTicketMappings" : [ + { + "clientNameKeyName" : "cn", + "encryptionKeyTypeKeyName" : "keyType", + "messageBufferKeyName" : "messageBuffer", + "realmKeyName" : "realm", + "serviceNameKeyName" : "sn", + "sessionKeyKeyName" : "clientKey", + "ticketKeyPath" : "tgt_ad" + }, + { + "clientNameKeyName" : "cn", + "encryptionKeyTypeKeyName" : "keyType", + "messageBufferKeyName" : "messageBuffer", + "realmKeyName" : "realm", + "serviceNameKeyName" : "sn", + "sessionKeyKeyName" : "clientKey", + "ticketKeyPath" : "tgt_cloud" + } + ], + "keyEndpointURL" : "https://login.microsoftonline.com//getkeydata", + "loginRequestEncryptionAlgorithm" : "ECDHE-A256GCM", + "nonceResponseKeypath" : "Nonce", + "previousRefreshTokenClaimName" : "previous_refresh_token", + "serverNonceClaimName" : "request_nonce", + "tokenEndpointURL" : "https://login.microsoftonline.com//oauth2/v2.0/token", + "uniqueIdentifierClaimName" : "oid", + "userSEPKeyBiometricPolicy" : "None (0)" +} + +User Configuration: + (null) + diff --git a/orbit/pkg/table/extension_darwin.go b/orbit/pkg/table/extension_darwin.go index 59f0b24007..3e6aa62e32 100644 --- a/orbit/pkg/table/extension_darwin.go +++ b/orbit/pkg/table/extension_darwin.go @@ -5,6 +5,7 @@ package table import ( "context" + "github.com/fleetdm/fleet/v4/orbit/pkg/table/app_sso_platform" "github.com/fleetdm/fleet/v4/orbit/pkg/table/authdb" "github.com/fleetdm/fleet/v4/orbit/pkg/table/codesign" "github.com/fleetdm/fleet/v4/orbit/pkg/table/csrutil_info" @@ -95,6 +96,8 @@ func PlatformTables(opts PluginOpts) ([]osquery.OsqueryPlugin, error) { dataflattentable.TablePlugin(log.Logger, dataflattentable.PlistType), // table name is "parse_plist" table.NewPlugin("codesign", codesign.Columns(), codesign.Generate), + + table.NewPlugin("app_sso_platform", app_sso_platform.Columns(), app_sso_platform.Generate), } // append platform specific tables diff --git a/schema/osquery_fleet_schema.json b/schema/osquery_fleet_schema.json index 3c63a706cb..54bb7934c1 100644 --- a/schema/osquery_fleet_schema.json +++ b/schema/osquery_fleet_schema.json @@ -561,6 +561,43 @@ ], "fleetRepoUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/app_schemes.yml" }, + { + "name": "app_sso_platform", + "platforms": [ + "darwin" + ], + "description": "Returns device and login information parsed from the \"app-sso platform -s\" command (\"Platform SSO\" extensions).", + "columns": [ + { + "name": "extension_identifier", + "type": "text", + "required": true, + "description": "Extension identifier of the Platform SSO extension (e.g. \"com.microsoft.CompanyPortalMac.ssoextension\")." + }, + { + "name": "realm", + "type": "text", + "required": true, + "description": "Realm of the user that logged via Platform SSO (e.g. \"KERBEROS.MICROSOFTONLINE.COM\")." + }, + { + "name": "device_id", + "type": "text", + "required": false, + "description": "Device ID extracted from \"Device Configuration\" -> \"deviceSigningCertificate\" -> Subject -> CommonName." + }, + { + "name": "user_principal_name", + "type": "text", + "required": false, + "description": "User principal name of the user that logged in via Platform SSO." + } + ], + "notes": "This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)).", + "evented": false, + "url": "https://fleetdm.com/tables/app_sso_platform", + "fleetRepoUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/app_sso_platform.yml" + }, { "name": "apparmor_events", "description": "Track AppArmor events.", diff --git a/schema/tables/app_sso_platform.yml b/schema/tables/app_sso_platform.yml new file mode 100644 index 0000000000..e00150da89 --- /dev/null +++ b/schema/tables/app_sso_platform.yml @@ -0,0 +1,23 @@ +name: app_sso_platform +platforms: + - darwin +description: Returns device and login information parsed from the "app-sso platform -s" command ("Platform SSO" extensions). +columns: + - name: extension_identifier + type: text + required: true + description: Extension identifier of the Platform SSO extension (e.g. "com.microsoft.CompanyPortalMac.ssoextension"). + - name: realm + type: text + required: true + description: Realm of the user that logged via Platform SSO (e.g. "KERBEROS.MICROSOFTONLINE.COM"). + - name: device_id + type: text + required: false + description: Device ID extracted from "Device Configuration" -> "deviceSigningCertificate" -> Subject -> CommonName. + - name: user_principal_name + type: text + required: false + description: User principal name of the user that logged in via Platform SSO. +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). +evented: false diff --git a/server/datastore/mysql/conditional_access_microsoft_test.go b/server/datastore/mysql/conditional_access_microsoft_test.go index 23dd090ff9..410aacceb7 100644 --- a/server/datastore/mysql/conditional_access_microsoft_test.go +++ b/server/datastore/mysql/conditional_access_microsoft_test.go @@ -184,4 +184,59 @@ func testConditionalAccessHosts(t *testing.T, ds *Datastore) { require.NotZero(t, s.UpdatedAt) require.Nil(t, s.Managed) require.Nil(t, s.Compliant) + + err = ds.SetHostConditionalAccessStatus(ctx, noTeamHost.ID, false, true) + require.NoError(t, err) + + // Simulate a device with same device ID but empty username. + // This can happen on workstations with two user macOS accounts where one is registered and another one is not. + err = ds.CreateHostConditionalAccessStatus(ctx, noTeamHost.ID, "entraDeviceID2", "") + require.NoError(t, err) + s, err = ds.LoadHostConditionalAccessStatus(ctx, noTeamHost.ID) + require.NoError(t, err) + require.Equal(t, noTeamHost.ID, s.HostID) + require.Equal(t, "entraDeviceID2", s.DeviceID) + require.Equal(t, "", s.UserPrincipalName) + require.Equal(t, "host1", s.DisplayName) + require.Equal(t, "15.4.1", s.OSVersion) + require.NotZero(t, s.CreatedAt) + require.NotZero(t, s.UpdatedAt) + require.Nil(t, s.Managed) + require.Nil(t, s.Compliant) + + err = ds.SetHostConditionalAccessStatus(ctx, noTeamHost.ID, false, true) + require.NoError(t, err) + + // Simulate now that the second user has logged in to Entra. + err = ds.CreateHostConditionalAccessStatus(ctx, noTeamHost.ID, "entraDeviceID2", "foobar3@example.onmicrosoft.com") + require.NoError(t, err) + s, err = ds.LoadHostConditionalAccessStatus(ctx, noTeamHost.ID) + require.NoError(t, err) + require.Equal(t, noTeamHost.ID, s.HostID) + require.Equal(t, "entraDeviceID2", s.DeviceID) + require.Equal(t, "foobar3@example.onmicrosoft.com", s.UserPrincipalName) + require.Equal(t, "host1", s.DisplayName) + require.Equal(t, "15.4.1", s.OSVersion) + require.NotZero(t, s.CreatedAt) + require.NotZero(t, s.UpdatedAt) + require.Nil(t, s.Managed) + require.Nil(t, s.Compliant) + + err = ds.SetHostConditionalAccessStatus(ctx, noTeamHost.ID, false, true) + require.NoError(t, err) + + // Simulate that the first user has logged in again to the workstation. + err = ds.CreateHostConditionalAccessStatus(ctx, noTeamHost.ID, "entraDeviceID2", "foobar@example.onmicrosoft.com") + require.NoError(t, err) + s, err = ds.LoadHostConditionalAccessStatus(ctx, noTeamHost.ID) + require.NoError(t, err) + require.Equal(t, noTeamHost.ID, s.HostID) + require.Equal(t, "entraDeviceID2", s.DeviceID) + require.Equal(t, "foobar@example.onmicrosoft.com", s.UserPrincipalName) + require.Equal(t, "host1", s.DisplayName) + require.Equal(t, "15.4.1", s.OSVersion) + require.NotZero(t, s.CreatedAt) + require.NotZero(t, s.UpdatedAt) + require.Nil(t, s.Managed) + require.Nil(t, s.Compliant) } diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index 506913d521..cb7c9a81f1 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -861,9 +861,10 @@ var windowsUpdateHistory = DetailQuery{ // entraIDDetails holds the query and ingestion function for Microsoft "Conditional access" feature. var entraIDDetails = DetailQuery{ - // The query ingests Entra's Device ID and User Principal Name of the account that logged in to the device (using Company Portal.app). - Query: `SELECT * FROM (SELECT common_name AS device_id FROM certificates WHERE issuer LIKE '/DC=net+DC=windows+CN=MS-Organization-Access+OU%' LIMIT 1) - CROSS JOIN (SELECT label as user_principal_name FROM keychain_items WHERE account = 'com.microsoft.workplacejoin.registeredUserPrincipalName' LIMIT 1);`, + // The query ingests Entra's Device ID and User Principal Name of the account + // that logged in to the device (using Company Portal.app with the Platform SSO extension). + Query: "SELECT * FROM app_sso_platform WHERE extension_identifier = 'com.microsoft.CompanyPortalMac.ssoextension' AND realm = 'KERBEROS.MICROSOFTONLINE.COM';", + Discovery: discoveryTable("app_sso_platform"), Platforms: []string{"darwin"}, DirectIngestFunc: directIngestEntraIDDetails, } @@ -1545,9 +1546,9 @@ func directIngestEntraIDDetails( return ctxerr.New(ctx, "empty Entra ID device_id") } userPrincipalName := row["user_principal_name"] - if userPrincipalName == "" { - return ctxerr.New(ctx, "empty Entra ID user_principal_name") - } + // userPrincipalName can be empty on macOS workstations with e.g. two accounts: + // one logged in to Entra and the other one not logged in. + // While the second one is logged in, it would report the same Device ID but empty user principal name. if err := ds.CreateHostConditionalAccessStatus(ctx, host.ID, deviceID, userPrincipalName); err != nil { return ctxerr.Wrap(ctx, err, "failed to create host conditional access status") diff --git a/tools/app-sso-platform/main.go b/tools/app-sso-platform/main.go new file mode 100644 index 0000000000..2a1ab32c7b --- /dev/null +++ b/tools/app-sso-platform/main.go @@ -0,0 +1,52 @@ +//go:build darwin +// +build darwin + +//go:debug x509negativeserial=1 + +// Package main is a macOS application to test the app_sso_platform table in the command line. +// Usage for SSO Platform extension for Microsoft Company Portal: +// "go run ./tools/app-sso-platform com.microsoft.CompanyPortalMac.ssoextension KERBEROS.MICROSOFTONLINE.COM" +package main + +import ( + "context" + "fmt" + "os" + + "github.com/fleetdm/fleet/v4/orbit/pkg/table/app_sso_platform" + "github.com/osquery/osquery-go/plugin/table" +) + +func main() { + if len(os.Args) < 3 { + fmt.Printf("usage: %s \n", os.Args[0]) + os.Exit(1) + } + extensionIdentifier := os.Args[1] + realm := os.Args[2] + + rows, err := app_sso_platform.Generate(context.Background(), table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "extension_identifier": { + Constraints: []table.Constraint{ + { + Operator: table.OperatorEquals, + Expression: extensionIdentifier, + }, + }, + }, + "realm": { + Constraints: []table.Constraint{ + { + Operator: table.OperatorEquals, + Expression: realm, + }, + }, + }, + }, + }) + if err != nil { + panic(err) + } + fmt.Printf("%+v\n", rows) +}