**Related issue:** Resolves #42757 ## Summary Resending or renewing the Okta conditional access profile leaves an orphaned SCEP certificate in the per-user macOS keychain, accumulating duplicates with every renewal. This PR auto-runs an existing keychain-cleanup script after a successful `InstallProfile` ack for the Okta CA profile, so admins no longer have to find and run the script manually. ## Root cause Investigation in the issue thread isolated the trigger: - The Okta CA `.mobileconfig` bundles `com.apple.security.scep` with `com.apple.security.identitypreference` in a single profile (macOS rejects the alternative — `Identity payload not found in same profile as identity preference payload`). - The Identity Preference payload creates a keychain-resident preference item that keeps the *old* cert pinned across profile replacement, even though the rewritten Identity Preference now points to the fresh SCEP enrollment. - EAP-TLS Wi-Fi profiles renew cleanly because they reference the cert via SystemConfiguration (`PayloadCertificateUUID`), not the keychain — so this isn't a generic SCEP-bundling issue. The team decision in the issue (`@sharon-fdm`) was to delete the duplicate certificate rather than restructure the profile. A standalone cleanup script already shipped at `docs/solutions/macos/scripts/delete-duplicate-scep-certificates.sh` and was linked from the Okta CA guide; admins had to find and run it. ## Approach Hook the existing Apple MDM `InstallProfile` ack path in `MDMAppleCheckinAndCommandService.CommandAndReportResults`, parallel to the existing ACME `CertificateList` follow-up. When the ack is for the Okta CA profile and status is `verifying`, enqueue an internal host script run that executes the cleanup script targeting the host's per-user MDM enrollment short name. Key properties: - **Single hook, three paths covered.** Admin "Resend" nulls the profile status and the reconciliation cron re-enqueues an `InstallProfile`; the SCEP renewal cron also re-issues `InstallProfile`. Both flow through the same ack handler this hook attaches to. - **Idempotent.** The cleanup script no-ops when only one matching cert is present, so triggering on initial installs (not just renewals) is safe and removes the need to distinguish "is this a renewal". - **Tightly gated.** Single indexed lookup keyed on `(host_uuid, command_uuid, profile_identifier, platform='darwin')`. Other SCEP-bearing profiles do not trigger the script. No work happens for hosts with no per-user enrollment. - **Internal-script semantics** (matches lock/unlock/wipe prior art). Runs even when scripts are globally disabled. Does not appear in the user-facing host activity feed. - **Failure-isolated.** Enqueue errors are logged but do not break the ack path; the renewal itself is what matters. - **Defense in depth on the shell call.** The macOS short name is validated against a strict regex (`^[A-Za-z0-9_][A-Za-z0-9_.-]*$`, ≤31 chars) before being interpolated, and POSIX single-quote-escaped on the way through. ## Files **New** - `server/service/conditional_access_cleanup.go` — `//go:embed` of the cleanup script, the hook helper `maybeRunOktaCACleanupScript`, the validated shell-wrapper builder, and the POSIX single-quote escape helper. - `server/service/conditional_access_cleanup_test.go` — unit coverage for username validation, shell escaping, the routing decisions of the hook helper (mock-based), and an embed-sync assertion against the docs copy. - `server/service/embedded_scripts/delete-duplicate-scep-certificates.sh` — embed source-of-truth copy, byte-for-byte equal to the public `docs/solutions/macos/scripts/` script. - `changes/42757-okta-conditional-access-duplicate-scep-cert-cleanup` — user-visible changes note. **Datastore** - `server/datastore/mysql/mdm.go` — `OktaCACleanupTargetForInstallCommand`: single SQL lookup that returns `(host_id, user_short_name, ok)` for the new hook. Returns `ok=false` for non-Okta profiles, non-darwin hosts, or hosts without a user-channel enrollment. - `server/datastore/mysql/scripts.go` — `NewInternalHostScriptExecutionRequest`: thin wrapper that routes through the existing internal-script codepath (`isInternal=true`) used by lock/unlock/wipe. Refactored the existing public method to share an internal helper. **Interface / mocks** - `server/fleet/conditional_access_idp.go` — exported `ConditionalAccessOktaProfileIdentifier`, `ConditionalAccessOktaCertificateCN`, and the new `OktaCACleanupTarget` struct, so both the template-render path and the SQL lookup can reference the same source of truth. - `server/fleet/datastore.go` — `OktaCACleanupTargetForInstallCommand` and `NewInternalHostScriptExecutionRequest` added to the `Datastore` interface. - `server/mock/datastore_mock.go` — regenerated (additions only). **Wiring** - `server/service/apple_mdm.go` — call into `maybeRunOktaCACleanupScript` from the InstallProfile `MDMDeliveryVerifying` branch, alongside the existing ACME `maybeQueueCertificateListForACMEProfile` follow-up. Warns on error rather than failing the ack. - `server/service/conditional_access_idp.go` — use the new `fleet.ConditionalAccessOktaCertificateCN` constant when rendering the profile template, eliminating the magic string duplication. **Tests touched** - `server/datastore/mysql/mdm_test.go` — integration test `testOktaCACleanupTargetForInstallCommand` covering the happy path, non-Okta profile, device-only enrollment, and unknown command. - `server/datastore/mysql/scripts_test.go` — `testNewInternalHostScriptExecutionRequest` confirming the internal flag is set correctly and the new entry only appears under the internal-only listing filter. - `server/service/apple_mdm_test.go` — added the new mock stub for `OktaCACleanupTargetForInstallCommandFunc` to `TestMDMCommandAndReportResultsProfileHandling` so the existing test continues to pass with the new hook in the codepath. - `server/service/conditional_access_idp_test.go` — the rendered-profile assertion now also pins on the shared `ConditionalAccessOktaProfileIdentifier` and `ConditionalAccessOktaCertificateCN` constants so the template can't drift from the SQL lookup.
408 lines
15 KiB
Go
408 lines
15 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/logging"
|
|
"github.com/fleetdm/fleet/v4/server/dev_mode"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const conditionalAccessAppleProfileTemplate = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>PayloadContent</key>
|
|
<array>
|
|
<!-- Trusted CA certificate -->
|
|
<dict>
|
|
<key>PayloadCertificateFileName</key>
|
|
<string>conditional_access_ca.der</string>
|
|
<key>PayloadContent</key>
|
|
<data>{{.CACertBase64}}</data>
|
|
<key>PayloadDescription</key>
|
|
<string>Fleet conditional access CA certificate</string>
|
|
<key>PayloadDisplayName</key>
|
|
<string>Fleet conditional access CA</string>
|
|
<key>PayloadIdentifier</key>
|
|
<string>com.fleetdm.conditional-access-ca</string>
|
|
<key>PayloadType</key>
|
|
<string>com.apple.security.root</string>
|
|
<key>PayloadUUID</key>
|
|
<string>{{.CACertUUID}}</string>
|
|
<key>PayloadVersion</key>
|
|
<integer>1</integer>
|
|
</dict>
|
|
<!-- SCEP configuration -->
|
|
<dict>
|
|
<key>PayloadContent</key>
|
|
<dict>
|
|
<key>URL</key>
|
|
<string>{{.SCEPURL}}</string>
|
|
<key>Challenge</key>
|
|
<string>{{.Challenge}}</string>
|
|
<key>Keysize</key>
|
|
<integer>2048</integer>
|
|
<key>Key Type</key>
|
|
<string>RSA</string>
|
|
<key>Key Usage</key>
|
|
<integer>5</integer>
|
|
<key>ExtendedKeyUsage</key>
|
|
<array>
|
|
<string>1.3.6.1.5.5.7.3.2</string>
|
|
</array>
|
|
<key>Subject</key>
|
|
<array>
|
|
<array>
|
|
<array>
|
|
<string>CN</string>
|
|
<string>{{.CertificateCN}}</string>
|
|
</array>
|
|
</array>
|
|
<array>
|
|
<array>
|
|
<string>OU</string>
|
|
<string>$FLEET_VAR_CERTIFICATE_RENEWAL_ID</string>
|
|
</array>
|
|
</array>
|
|
</array>
|
|
<key>SubjectAltName</key>
|
|
<dict>
|
|
<key>uniformResourceIdentifier</key>
|
|
<array>
|
|
<string>urn:device:apple:uuid:%HardwareUUID%</string>
|
|
</array>
|
|
</dict>
|
|
<key>Retries</key>
|
|
<integer>3</integer>
|
|
<key>RetryDelay</key>
|
|
<integer>10</integer>
|
|
<!-- ACL for browser access -->
|
|
<key>AllowAllAppsAccess</key>
|
|
<true/>
|
|
<key>KeyIsExtractable</key>
|
|
<false/>
|
|
</dict>
|
|
<key>PayloadDescription</key>
|
|
<string>Configures SCEP for Fleet conditional access for Okta certificate</string>
|
|
<key>PayloadDisplayName</key>
|
|
<string>Fleet conditional access SCEP</string>
|
|
<key>PayloadIdentifier</key>
|
|
<string>com.fleetdm.conditional-access-scep</string>
|
|
<key>PayloadType</key>
|
|
<string>com.apple.security.scep</string>
|
|
<key>PayloadUUID</key>
|
|
<string>{{.SCEPPayloadUUID}}</string>
|
|
<key>PayloadVersion</key>
|
|
<integer>1</integer>
|
|
</dict>
|
|
<!-- Identity preference for mTLS endpoint -->
|
|
<dict>
|
|
<key>Name</key>
|
|
<string>{{.MTLSURL}}</string>
|
|
<key>PayloadCertificateUUID</key>
|
|
<string>{{.SCEPPayloadUUID}}</string>
|
|
<key>PayloadDescription</key>
|
|
<string>Identity preference for mTLS endpoints</string>
|
|
<key>PayloadDisplayName</key>
|
|
<string>Fleet mTLS identity preference</string>
|
|
<key>PayloadIdentifier</key>
|
|
<string>com.fleetdm.conditional-access-preference</string>
|
|
<key>PayloadType</key>
|
|
<string>com.apple.security.identitypreference</string>
|
|
<key>PayloadUUID</key>
|
|
<string>{{.IdentityPrefUUID}}</string>
|
|
<key>PayloadVersion</key>
|
|
<integer>1</integer>
|
|
</dict>
|
|
<!-- Chrome web browser configuration -->
|
|
<dict>
|
|
<key>PayloadType</key>
|
|
<string>com.apple.ManagedClient.preferences</string>
|
|
<key>PayloadVersion</key>
|
|
<integer>1</integer>
|
|
<key>PayloadIdentifier</key>
|
|
<string>com.fleetdm.chrome.certs</string>
|
|
<key>PayloadUUID</key>
|
|
<string>{{.ChromeConfigUUID}}</string>
|
|
<key>PayloadDisplayName</key>
|
|
<string>Chrome mTLS auto-select</string>
|
|
<key>PayloadContent</key>
|
|
<dict>
|
|
<key>com.google.Chrome</key>
|
|
<dict>
|
|
<key>Forced</key>
|
|
<array>
|
|
<dict>
|
|
<key>mcx_preference_settings</key>
|
|
<dict>
|
|
<key>AllowPolicyInIncognito</key>
|
|
<true/>
|
|
<key>AutoSelectCertificateForUrls</key>
|
|
<array>
|
|
<!-- MUST be stringified JSON -->
|
|
<string>{"pattern":"{{.MTLSURL}}","filter":{"SUBJECT":{"CN":"{{.CertificateCN}}"}}}</string>
|
|
</array>
|
|
</dict>
|
|
</dict>
|
|
</array>
|
|
</dict>
|
|
</dict>
|
|
</dict>
|
|
</array>
|
|
<key>PayloadDescription</key>
|
|
<string>Configures SCEP enrollment for Okta conditional access</string>
|
|
<key>PayloadDisplayName</key>
|
|
<string>Fleet conditional access for Okta</string>
|
|
<key>PayloadIdentifier</key>
|
|
<string>com.fleetdm.conditional-access-okta</string>
|
|
<key>PayloadOrganization</key>
|
|
<string>Fleet Device Management</string>
|
|
<key>PayloadRemovalDisallowed</key>
|
|
<false/>
|
|
<key>PayloadScope</key>
|
|
<string>User</string>
|
|
<key>PayloadType</key>
|
|
<string>Configuration</string>
|
|
<key>PayloadUUID</key>
|
|
<string>{{.RootPayloadUUID}}</string>
|
|
<key>PayloadVersion</key>
|
|
<integer>1</integer>
|
|
</dict>
|
|
</plist>
|
|
`
|
|
|
|
var conditionalAccessAppleProfileTemplateParsed = template.Must(template.New("conditionalAccessAppleProfile").Parse(
|
|
conditionalAccessAppleProfileTemplate))
|
|
|
|
// fleetConditionalAccessNamespace is a custom UUID namespace for Fleet Okta conditional access profiles.
|
|
// Generated using: uuid.NewSHA1(uuid.NameSpaceURL, []byte("https://fleetdm.com/learn-more-about/okta-conditional-access"))
|
|
// This ensures UUIDs are unique to Fleet's Okta conditional access feature and won't collide with other systems.
|
|
var fleetConditionalAccessNamespace = uuid.Must(uuid.Parse("fe5c0046-e83e-5a1d-9693-ace1348d34ec"))
|
|
|
|
// generateDeterministicUUID generates a UUID v5 based on the server URL and a component name.
|
|
// This ensures the same server always generates the same UUIDs for profile components.
|
|
func generateDeterministicUUID(serverURL, component string) string {
|
|
// Use Fleet's conditional access namespace to avoid collisions
|
|
// Create a deterministic UUID based on serverURL + component
|
|
name := fmt.Sprintf("%s:%s", serverURL, component)
|
|
return uuid.NewSHA1(fleetConditionalAccessNamespace, []byte(name)).String()
|
|
}
|
|
|
|
type appleProfileTemplateData struct {
|
|
CACertBase64 string
|
|
SCEPURL string
|
|
Challenge string
|
|
CertificateCN string
|
|
MTLSURL string
|
|
CACertUUID string
|
|
SCEPPayloadUUID string
|
|
IdentityPrefUUID string
|
|
ChromeConfigUUID string
|
|
RootPayloadUUID string
|
|
}
|
|
|
|
type conditionalAccessGetIdPSigningCertRequest struct{}
|
|
|
|
type conditionalAccessGetIdPSigningCertResponse struct {
|
|
CertPEM []byte
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r conditionalAccessGetIdPSigningCertResponse) Error() error { return r.Err }
|
|
|
|
func (r conditionalAccessGetIdPSigningCertResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(r.CertPEM)), 10))
|
|
w.Header().Set("Content-Type", "application/x-pem-file")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Content-Disposition", "attachment; filename=\"fleet-idp-signing-cert.pem\"")
|
|
|
|
// OK to just log the error here as writing anything on `http.ResponseWriter` sets the status code to 200 (and it can't be
|
|
// changed.) Clients should rely on matching content-length with the header provided
|
|
n, err := w.Write(r.CertPEM)
|
|
if err != nil {
|
|
logging.WithExtras(ctx, "err", err, "bytes_written", n)
|
|
}
|
|
}
|
|
|
|
func conditionalAccessGetIdPSigningCertEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
certPEM, err := svc.ConditionalAccessGetIdPSigningCert(ctx)
|
|
if err != nil {
|
|
return conditionalAccessGetIdPSigningCertResponse{Err: err}, nil
|
|
}
|
|
return conditionalAccessGetIdPSigningCertResponse{
|
|
CertPEM: certPEM,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) ConditionalAccessGetIdPSigningCert(ctx context.Context) (certPEM []byte, err error) {
|
|
// Check user is authorized to read conditional access Okta IdP certificate
|
|
if err := svc.authz.Authorize(ctx, &fleet.ConditionalAccessIDPAssets{}, fleet.ActionRead); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to authorize")
|
|
}
|
|
|
|
// Check that server private key is configured
|
|
if len(svc.config.Server.PrivateKey) == 0 {
|
|
return nil, &fleet.BadRequestError{Message: "Fleet server private key is not configured. Learn more: https://fleetdm.com/learn-more-about/fleet-server-private-key"}
|
|
}
|
|
|
|
// Load IdP certificate from mdm_config_assets
|
|
assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
|
|
fleet.MDMAssetConditionalAccessIDPCert,
|
|
}, nil)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to load IdP certificate")
|
|
}
|
|
|
|
certAsset, ok := assets[fleet.MDMAssetConditionalAccessIDPCert]
|
|
if !ok {
|
|
return nil, ctxerr.New(ctx, "IdP certificate not configured")
|
|
}
|
|
|
|
return certAsset.Value, nil
|
|
}
|
|
|
|
type conditionalAccessGetIdPAppleProfileResponse struct {
|
|
ProfileData []byte
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r conditionalAccessGetIdPAppleProfileResponse) Error() error { return r.Err }
|
|
|
|
func (r conditionalAccessGetIdPAppleProfileResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(r.ProfileData)), 10))
|
|
w.Header().Set("Content-Type", "application/x-apple-aspen-config")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Content-Disposition", "attachment; filename=\"fleet-conditional-access.mobileconfig\"")
|
|
|
|
// OK to just log the error here as writing anything on `http.ResponseWriter` sets the status code to 200 (and it can't be
|
|
// changed.) Clients should rely on matching content-length with the header provided
|
|
n, err := w.Write(r.ProfileData)
|
|
if err != nil {
|
|
logging.WithExtras(ctx, "err", err, "bytes_written", n)
|
|
}
|
|
}
|
|
|
|
func conditionalAccessGetIdPAppleProfileEndpoint(ctx context.Context, _ interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
profileData, err := svc.ConditionalAccessGetIdPAppleProfile(ctx)
|
|
if err != nil {
|
|
return conditionalAccessGetIdPAppleProfileResponse{Err: err}, nil
|
|
}
|
|
return conditionalAccessGetIdPAppleProfileResponse{
|
|
ProfileData: profileData,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) ConditionalAccessGetIdPAppleProfile(ctx context.Context) (profileData []byte, err error) {
|
|
// Check user is authorized to read conditional access Apple profile
|
|
if err := svc.authz.Authorize(ctx, &fleet.ConditionalAccessIDPAssets{}, fleet.ActionRead); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to authorize")
|
|
}
|
|
|
|
// Check that server private key is configured
|
|
if len(svc.config.Server.PrivateKey) == 0 {
|
|
return nil, &fleet.BadRequestError{Message: "Fleet server private key is not configured. Learn more: https://fleetdm.com/learn-more-about/fleet-server-private-key"}
|
|
}
|
|
|
|
// Load CA certificate for SCEP from mdm_config_assets
|
|
assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
|
|
fleet.MDMAssetConditionalAccessCACert,
|
|
}, nil)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to load conditional access CA certificate")
|
|
}
|
|
|
|
caCertAsset, ok := assets[fleet.MDMAssetConditionalAccessCACert]
|
|
if !ok {
|
|
return nil, ctxerr.New(ctx, "conditional access CA certificate not configured")
|
|
}
|
|
|
|
// Parse PEM certificate
|
|
block, _ := pem.Decode(caCertAsset.Value)
|
|
if block == nil {
|
|
return nil, ctxerr.New(ctx, "failed to decode CA certificate PEM")
|
|
}
|
|
|
|
// Parse DER certificate
|
|
_, err = x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to parse CA certificate")
|
|
}
|
|
|
|
// Base64 encode the DER certificate for the profile
|
|
caCertBase64 := base64.StdEncoding.EncodeToString(block.Bytes)
|
|
|
|
// Get app config for server URL
|
|
appConfig, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to load app config")
|
|
}
|
|
if strings.TrimSpace(appConfig.ServerSettings.ServerURL) == "" {
|
|
return nil, &fleet.BadRequestError{Message: "server URL is not configured"}
|
|
}
|
|
|
|
// Construct SCEP URL using net/url package
|
|
parsedServerURL, err := url.Parse(appConfig.ServerSettings.ServerURL)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to parse server URL")
|
|
}
|
|
scepURL := parsedServerURL.JoinPath("/api/fleet/conditional_access/scep").String()
|
|
|
|
// Get global enroll secrets
|
|
secrets, err := svc.ds.GetEnrollSecrets(ctx, nil)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to get enroll secrets")
|
|
}
|
|
if len(secrets) == 0 {
|
|
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: "global enroll secret is not configured"})
|
|
}
|
|
|
|
// Use the first global enroll secret as the challenge
|
|
challenge := secrets[0].Secret
|
|
|
|
// Get mTLS URL using ConditionalAccessIdPSSOURL
|
|
mtlsURL, err := appConfig.ConditionalAccessIdPSSOURL(dev_mode.Env)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to get mTLS URL")
|
|
}
|
|
|
|
// Generate deterministic UUIDs based on server URL
|
|
serverURL := appConfig.ServerSettings.ServerURL
|
|
caCertUUID := generateDeterministicUUID(serverURL, "conditional-access-ca-cert")
|
|
scepPayloadUUID := generateDeterministicUUID(serverURL, "conditional-access-scep")
|
|
identityPrefUUID := generateDeterministicUUID(serverURL, "conditional-access-identity-pref")
|
|
chromeConfigUUID := generateDeterministicUUID(serverURL, "conditional-access-chrome-config")
|
|
rootPayloadUUID := generateDeterministicUUID(serverURL, "conditional-access-root-payload")
|
|
|
|
// Execute template
|
|
var buf bytes.Buffer
|
|
if err := conditionalAccessAppleProfileTemplateParsed.Execute(&buf, appleProfileTemplateData{
|
|
CACertBase64: caCertBase64,
|
|
SCEPURL: scepURL,
|
|
Challenge: challenge,
|
|
CertificateCN: fleet.ConditionalAccessOktaCertificateCN,
|
|
MTLSURL: mtlsURL,
|
|
CACertUUID: caCertUUID,
|
|
SCEPPayloadUUID: scepPayloadUUID,
|
|
IdentityPrefUUID: identityPrefUUID,
|
|
ChromeConfigUUID: chromeConfigUUID,
|
|
RootPayloadUUID: rootPayloadUUID,
|
|
}); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to execute profile template")
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|