feat: native app links for passkeys (#12580)

# Which Problems Are Solved

- Native iOS/Android passkeys need OS trust files on the ZITADEL domain;
without them, Associated Domains / App Links verification fails.
- Operators could not configure iOS Team ID + Bundle ID or Android
package name + SHA-256 fingerprints on OIDC apps.
- `/.well-known/apple-app-site-association` and
`/.well-known/assetlinks.json` were not served from application config.

# How the Problems Are Solved

- Add iOS/Android app-link fields on OIDC app create/update (Application
API v2 + Management), with validation.
- Persist and project those fields; query active app-link configs
instance-wide.
- Serve AASA (`webcredentials`) and Digital Asset Links
(`get_login_creds`) from well-known paths, with configurable
`Cache-Control` and fingerprint normalization at serve time.
- Console UI to edit the fields, with links to the well-known endpoints.
- Operator docs for configuration, endpoints, caching, and verification.

# Additional Changes

- Document on API fields that well-known responses may be HTTP-cached
and platform verifiers may delay propagation.
- Runtime config: `WellKnown.AppLinksCacheControlMaxAge` (default `5m`;
`0` → `no-store`).

# Additional Context

- Closes #12497
- Implemented and reviewed as stack:
  - #12531 API contract
  - #12532 storage wiring
  - #12536 well-known endpoints
  - #12537 console
  - #12547 docs

---------

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Livio Spring
2026-08-12 08:04:27 +02:00
committed by GitHub
co-authored by Cursor
parent 2bd42e8fc4
commit 318bfc36d7
68 changed files with 3227 additions and 467 deletions
+4 -1
View File
@@ -158,7 +158,10 @@ func TestCommandSide_AddInstanceDomain(t *testing.T) {
"",
domain.LoginVersionUnspecified,
"",
),
"",
"",
"",
nil),
),
),
expectPush(
+8 -2
View File
@@ -125,7 +125,10 @@ func oidcAppEvents(ctx context.Context, orgID, projectID, id, name, clientID str
"",
domain.LoginVersionUnspecified,
"",
),
"",
"",
"",
nil),
}
}
@@ -468,7 +471,10 @@ func generatedDomainFilters(instanceID, orgID, projectID, appID, generatedDomain
"",
domain.LoginVersionUnspecified,
"",
),
"",
"",
"",
nil),
),
expectFilter(
func() eventstore.Event {
+44 -19
View File
@@ -20,24 +20,28 @@ import (
type addOIDCApp struct {
AddApp
Version domain.OIDCVersion
RedirectUris []string
ResponseTypes []domain.OIDCResponseType
GrantTypes []domain.OIDCGrantType
ApplicationType domain.OIDCApplicationType
AuthMethodType domain.OIDCAuthMethodType
PostLogoutRedirectUris []string
DevMode bool
AccessTokenType domain.OIDCTokenType
AccessTokenRoleAssertion bool
IDTokenRoleAssertion bool
IDTokenUserinfoAssertion bool
ClockSkew time.Duration
AdditionalOrigins []string
SkipSuccessPageForNativeApp bool
BackChannelLogoutURI string
LoginVersion domain.LoginVersion
LoginBaseURI string
Version domain.OIDCVersion
RedirectUris []string
ResponseTypes []domain.OIDCResponseType
GrantTypes []domain.OIDCGrantType
ApplicationType domain.OIDCApplicationType
AuthMethodType domain.OIDCAuthMethodType
PostLogoutRedirectUris []string
DevMode bool
AccessTokenType domain.OIDCTokenType
AccessTokenRoleAssertion bool
IDTokenRoleAssertion bool
IDTokenUserinfoAssertion bool
ClockSkew time.Duration
AdditionalOrigins []string
SkipSuccessPageForNativeApp bool
BackChannelLogoutURI string
LoginVersion domain.LoginVersion
LoginBaseURI string
IOSTeamID string
IOSBundleID string
AndroidPackageName string
AndroidSHA256CertFingerprints []string
ClientID string
ClientSecret string
@@ -118,6 +122,10 @@ func (c *Commands) AddOIDCAppCommand(app *addOIDCApp) preparation.Validation {
app.BackChannelLogoutURI,
app.LoginVersion,
app.LoginBaseURI,
app.IOSTeamID,
app.IOSBundleID,
app.AndroidPackageName,
app.AndroidSHA256CertFingerprints,
),
}, nil
}, nil
@@ -251,6 +259,10 @@ func (c *Commands) pushOIDCApplication(ctx context.Context, addedApplication *OI
backchannelLogoutURI,
gu.Value(oidcApp.LoginVersion),
strings.TrimSpace(gu.Value(oidcApp.LoginBaseURI)),
strings.TrimSpace(gu.Value(oidcApp.IOSTeamID)),
strings.TrimSpace(gu.Value(oidcApp.IOSBundleID)),
strings.TrimSpace(gu.Value(oidcApp.AndroidPackageName)),
trimStringSliceWhiteSpaces(oidcApp.AndroidSHA256CertFingerprints),
))
events = append(events, extraEvents...)
@@ -340,7 +352,7 @@ func (c *Commands) UpdateOIDCApplication(ctx context.Context, oidc *domain.OIDCA
// UpdateDynamicOIDCClient). It reports whether anything actually changed.
func (c *Commands) oidcApplicationChangeEvent(ctx context.Context, existingOIDC *OIDCApplicationWriteModel, oidc *domain.OIDCApp) (*project_repo.OIDCConfigChangedEvent, bool, error) {
projectAgg := ProjectAggregateFromWriteModelWithCTX(ctx, &existingOIDC.WriteModel)
var backChannelLogout, loginBaseURI *string
var backChannelLogout, loginBaseURI, iosTeamID, iosBundleID, androidPackageName *string
if oidc.BackChannelLogoutURI != nil {
bcl, err := c.validateBackchannelLogoutURI(oidc)
if err != nil {
@@ -352,6 +364,15 @@ func (c *Commands) oidcApplicationChangeEvent(ctx context.Context, existingOIDC
if oidc.LoginBaseURI != nil {
loginBaseURI = gu.Ptr(strings.TrimSpace(*oidc.LoginBaseURI))
}
if oidc.IOSTeamID != nil {
iosTeamID = gu.Ptr(strings.TrimSpace(*oidc.IOSTeamID))
}
if oidc.IOSBundleID != nil {
iosBundleID = gu.Ptr(strings.TrimSpace(*oidc.IOSBundleID))
}
if oidc.AndroidPackageName != nil {
androidPackageName = gu.Ptr(strings.TrimSpace(*oidc.AndroidPackageName))
}
return existingOIDC.NewChangedEvent(
ctx,
@@ -375,6 +396,10 @@ func (c *Commands) oidcApplicationChangeEvent(ctx context.Context, existingOIDC
backChannelLogout,
oidc.LoginVersion,
loginBaseURI,
iosTeamID,
iosBundleID,
androidPackageName,
trimStringSliceWhiteSpaces(oidc.AndroidSHA256CertFingerprints),
)
}
@@ -234,7 +234,10 @@ func TestCommandSide_AddDynamicOIDCClient(t *testing.T) {
"",
domain.LoginVersionUnspecified,
"",
),
"",
"",
"",
nil),
// The registration access token (RFC 7592 §3) is persisted in the same
// push as the application, so a registered client is never left
// unmanageable.
@@ -336,7 +339,10 @@ func TestCommandSide_AddDynamicOIDCClient(t *testing.T) {
"",
domain.LoginVersionUnspecified,
"",
),
"",
"",
"",
nil),
project.NewOIDCConfigRegistrationTokenChangedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
@@ -581,7 +587,11 @@ func TestCommandSide_UpdateDynamicOIDCClient(t *testing.T) {
domain.OIDCApplicationTypeWeb, domain.OIDCAuthMethodTypeNone,
[]string{}, false, domain.OIDCTokenTypeBearer,
false, false, false, 0, []string{}, false, "",
domain.LoginVersionUnspecified, "")),
domain.LoginVersionUnspecified, "",
"",
"",
"",
nil)),
}
}
sameMetadata := &domain.OIDCApp{
@@ -14,32 +14,36 @@ import (
type OIDCApplicationWriteModel struct {
eventstore.WriteModel
AppID string
AppName string
ClientID string
HashedSecret string
ClientSecretString string
RedirectUris []string
ResponseTypes []domain.OIDCResponseType
GrantTypes []domain.OIDCGrantType
ApplicationType domain.OIDCApplicationType
AuthMethodType domain.OIDCAuthMethodType
PostLogoutRedirectUris []string
OIDCVersion domain.OIDCVersion
Compliance *domain.Compliance
DevMode bool
AccessTokenType domain.OIDCTokenType
AccessTokenRoleAssertion bool
IDTokenRoleAssertion bool
IDTokenUserinfoAssertion bool
ClockSkew time.Duration
State domain.AppState
AdditionalOrigins []string
SkipNativeAppSuccessPage bool
BackChannelLogoutURI string
LoginVersion domain.LoginVersion
LoginBaseURI string
oidc bool
AppID string
AppName string
ClientID string
HashedSecret string
ClientSecretString string
RedirectUris []string
ResponseTypes []domain.OIDCResponseType
GrantTypes []domain.OIDCGrantType
ApplicationType domain.OIDCApplicationType
AuthMethodType domain.OIDCAuthMethodType
PostLogoutRedirectUris []string
OIDCVersion domain.OIDCVersion
Compliance *domain.Compliance
DevMode bool
AccessTokenType domain.OIDCTokenType
AccessTokenRoleAssertion bool
IDTokenRoleAssertion bool
IDTokenUserinfoAssertion bool
ClockSkew time.Duration
State domain.AppState
AdditionalOrigins []string
SkipNativeAppSuccessPage bool
BackChannelLogoutURI string
LoginVersion domain.LoginVersion
LoginBaseURI string
IOSTeamID string
IOSBundleID string
AndroidPackageName string
AndroidSHA256CertFingerprints []string
oidc bool
}
func NewOIDCApplicationWriteModelWithAppID(projectID, appID, resourceOwner string) *OIDCApplicationWriteModel {
@@ -140,6 +144,10 @@ func (wm *OIDCApplicationWriteModel) Reduce() error {
wm.BackChannelLogoutURI = ""
wm.LoginVersion = domain.LoginVersionUnspecified
wm.LoginBaseURI = ""
wm.IOSTeamID = ""
wm.IOSBundleID = ""
wm.AndroidPackageName = ""
wm.AndroidSHA256CertFingerprints = nil
wm.oidc = false
wm.AppName = e.Name
wm.State = domain.AppStateActive
@@ -189,6 +197,10 @@ func (wm *OIDCApplicationWriteModel) Reduce() error {
wm.BackChannelLogoutURI = ""
wm.LoginVersion = domain.LoginVersionUnspecified
wm.LoginBaseURI = ""
wm.IOSTeamID = ""
wm.IOSBundleID = ""
wm.AndroidPackageName = ""
wm.AndroidSHA256CertFingerprints = nil
wm.oidc = false
wm.State = domain.AppStateRemoved
}
@@ -218,6 +230,10 @@ func (wm *OIDCApplicationWriteModel) appendAddOIDCEvent(e *project.OIDCConfigAdd
wm.BackChannelLogoutURI = e.BackChannelLogoutURI
wm.LoginVersion = e.LoginVersion
wm.LoginBaseURI = e.LoginBaseURI
wm.IOSTeamID = e.IOSTeamID
wm.IOSBundleID = e.IOSBundleID
wm.AndroidPackageName = e.AndroidPackageName
wm.AndroidSHA256CertFingerprints = e.AndroidSHA256CertFingerprints
}
func (wm *OIDCApplicationWriteModel) appendChangeOIDCEvent(e *project.OIDCConfigChangedEvent) {
@@ -275,6 +291,18 @@ func (wm *OIDCApplicationWriteModel) appendChangeOIDCEvent(e *project.OIDCConfig
if e.LoginBaseURI != nil {
wm.LoginBaseURI = *e.LoginBaseURI
}
if e.IOSTeamID != nil {
wm.IOSTeamID = *e.IOSTeamID
}
if e.IOSBundleID != nil {
wm.IOSBundleID = *e.IOSBundleID
}
if e.AndroidPackageName != nil {
wm.AndroidPackageName = *e.AndroidPackageName
}
if e.AndroidSHA256CertFingerprints != nil {
wm.AndroidSHA256CertFingerprints = *e.AndroidSHA256CertFingerprints
}
}
func (wm *OIDCApplicationWriteModel) Query() *eventstore.SearchQueryBuilder {
@@ -320,6 +348,10 @@ func (wm *OIDCApplicationWriteModel) NewChangedEvent(
backChannelLogoutURI *string,
loginVersion *domain.LoginVersion,
loginBaseURI *string,
iosTeamID *string,
iosBundleID *string,
androidPackageName *string,
androidSHA256CertFingerprints []string,
) (*project.OIDCConfigChangedEvent, bool, error) {
changes := make([]project.OIDCConfigChanges, 0)
var err error
@@ -378,6 +410,18 @@ func (wm *OIDCApplicationWriteModel) NewChangedEvent(
if loginBaseURI != nil && wm.LoginBaseURI != *loginBaseURI {
changes = append(changes, project.ChangeOIDCLoginBaseURI(*loginBaseURI))
}
if iosTeamID != nil && wm.IOSTeamID != *iosTeamID {
changes = append(changes, project.ChangeIOSTeamID(*iosTeamID))
}
if iosBundleID != nil && wm.IOSBundleID != *iosBundleID {
changes = append(changes, project.ChangeIOSBundleID(*iosBundleID))
}
if androidPackageName != nil && wm.AndroidPackageName != *androidPackageName {
changes = append(changes, project.ChangeAndroidPackageName(*androidPackageName))
}
if androidSHA256CertFingerprints != nil && !slices.Equal(wm.AndroidSHA256CertFingerprints, androidSHA256CertFingerprints) {
changes = append(changes, project.ChangeAndroidSHA256CertFingerprints(androidSHA256CertFingerprints))
}
if len(changes) == 0 {
return nil, false, nil
@@ -0,0 +1,88 @@
package command
import (
"context"
"testing"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/project"
)
func TestOIDCApplicationWriteModel_NewChangedEvent_AppLinks(t *testing.T) {
t.Parallel()
agg := &project.NewAggregate("project-id", "org-id").Aggregate
base := func() *OIDCApplicationWriteModel {
return &OIDCApplicationWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: "project-id",
ResourceOwner: "org-id",
},
AppID: "app-id",
IOSTeamID: "OLDTEAM",
IOSBundleID: "com.old.app",
AndroidPackageName: "com.old.app",
AndroidSHA256CertFingerprints: []string{"AA:AA"},
}
}
t.Run("omit keeps existing", func(t *testing.T) {
t.Parallel()
wm := base()
event, hasChanged, err := wm.NewChangedEvent(
context.Background(), agg, "app-id",
nil, nil, nil, nil, nil, nil, nil, nil,
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
nil, nil, nil, nil,
)
require.NoError(t, err)
assert.False(t, hasChanged)
assert.Nil(t, event)
})
t.Run("set changes fields", func(t *testing.T) {
t.Parallel()
wm := base()
event, hasChanged, err := wm.NewChangedEvent(
context.Background(), agg, "app-id",
nil, nil, nil, nil, nil, nil, nil, nil,
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
gu.Ptr("NEWTEAM"),
gu.Ptr("com.new.app"),
gu.Ptr("com.new.app"),
[]string{"BB:BB"},
)
require.NoError(t, err)
require.True(t, hasChanged)
require.NotNil(t, event)
assert.Equal(t, gu.Ptr("NEWTEAM"), event.IOSTeamID)
assert.Equal(t, gu.Ptr("com.new.app"), event.IOSBundleID)
assert.Equal(t, gu.Ptr("com.new.app"), event.AndroidPackageName)
assert.Equal(t, &[]string{"BB:BB"}, event.AndroidSHA256CertFingerprints)
})
t.Run("clear with empty values", func(t *testing.T) {
t.Parallel()
wm := base()
event, hasChanged, err := wm.NewChangedEvent(
context.Background(), agg, "app-id",
nil, nil, nil, nil, nil, nil, nil, nil,
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
gu.Ptr(""),
gu.Ptr(""),
gu.Ptr(""),
[]string{},
)
require.NoError(t, err)
require.True(t, hasChanged)
require.NotNil(t, event)
assert.Equal(t, gu.Ptr(""), event.IOSTeamID)
assert.Equal(t, gu.Ptr(""), event.IOSBundleID)
assert.Equal(t, gu.Ptr(""), event.AndroidPackageName)
assert.Equal(t, &[]string{}, event.AndroidSHA256CertFingerprints)
})
}
@@ -176,7 +176,10 @@ func TestAddOIDCApp(t *testing.T) {
"",
domain.LoginVersionUnspecified,
"",
),
"",
"",
"",
nil),
},
},
},
@@ -244,7 +247,10 @@ func TestAddOIDCApp(t *testing.T) {
"",
domain.LoginVersionUnspecified,
"",
),
"",
"",
"",
nil),
},
},
},
@@ -312,7 +318,10 @@ func TestAddOIDCApp(t *testing.T) {
"",
domain.LoginVersionUnspecified,
"",
),
"",
"",
"",
nil),
},
},
},
@@ -380,7 +389,10 @@ func TestAddOIDCApp(t *testing.T) {
"",
domain.LoginVersionUnspecified,
"",
),
"",
"",
"",
nil),
},
},
},
@@ -534,7 +546,10 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) {
"https://test.ch/backchannel",
domain.LoginVersion2,
"https://login.test.ch",
),
"",
"",
"",
nil),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "app1", "client1"),
@@ -643,7 +658,10 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) {
"https://test.ch/backchannel",
domain.LoginVersion2,
"https://login.test.ch",
),
"",
"",
"",
nil),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "client1"),
@@ -753,7 +771,10 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) {
"https://test.ch/backchannel",
domain.LoginVersion2,
"https://login.test.ch",
),
"",
"",
"",
nil),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "app1", "client1"),
@@ -1072,7 +1093,10 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) {
"https://test.ch/backchannel",
domain.LoginVersion2,
"https://login.test.ch",
),
"",
"",
"",
nil),
),
),
expectFilter(),
@@ -1147,7 +1171,10 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) {
"https://test.ch/backchannel",
domain.LoginVersion2,
"https://login.test.ch",
),
"",
"",
"",
nil),
),
),
expectFilter(),
@@ -1222,7 +1249,10 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) {
"https://test.ch/backchannel",
domain.LoginVersion1,
"",
),
"",
"",
"",
nil),
),
),
expectFilter(),
@@ -1316,7 +1346,10 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) {
"",
domain.LoginVersionUnspecified,
"",
),
"",
"",
"",
nil),
),
),
expectFilter(),
@@ -1376,7 +1409,10 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) {
"",
domain.LoginVersionUnspecified,
"",
),
"",
"",
"",
nil),
),
),
expectFilter(),
@@ -1531,7 +1567,10 @@ func TestCommandSide_ChangeOIDCApplicationSecret(t *testing.T) {
"",
domain.LoginVersionUnspecified,
"",
),
"",
"",
"",
nil),
),
),
expectPush(
@@ -118,7 +118,10 @@ func TestCommandSide_ChangeApplicationSecret(t *testing.T) {
"",
domain.LoginVersionUnspecified,
"",
),
"",
"",
"",
nil),
),
),
expectPush(
+36 -23
View File
@@ -29,32 +29,45 @@ func projectGrantWriteModelToProjectGrant(writeModel *ProjectGrantWriteModel) *d
func oidcWriteModelToOIDCConfig(writeModel *OIDCApplicationWriteModel) *domain.OIDCApp {
return &domain.OIDCApp{
ObjectRoot: writeModelToObjectRoot(writeModel.WriteModel),
AppID: writeModel.AppID,
AppName: writeModel.AppName,
State: writeModel.State,
ClientID: writeModel.ClientID,
RedirectUris: writeModel.RedirectUris,
ResponseTypes: writeModel.ResponseTypes,
GrantTypes: writeModel.GrantTypes,
ApplicationType: gu.Ptr(writeModel.ApplicationType),
AuthMethodType: gu.Ptr(writeModel.AuthMethodType),
PostLogoutRedirectUris: writeModel.PostLogoutRedirectUris,
OIDCVersion: gu.Ptr(writeModel.OIDCVersion),
DevMode: gu.Ptr(writeModel.DevMode),
AccessTokenType: gu.Ptr(writeModel.AccessTokenType),
AccessTokenRoleAssertion: gu.Ptr(writeModel.AccessTokenRoleAssertion),
IDTokenRoleAssertion: gu.Ptr(writeModel.IDTokenRoleAssertion),
IDTokenUserinfoAssertion: gu.Ptr(writeModel.IDTokenUserinfoAssertion),
ClockSkew: gu.Ptr(writeModel.ClockSkew),
AdditionalOrigins: writeModel.AdditionalOrigins,
SkipNativeAppSuccessPage: gu.Ptr(writeModel.SkipNativeAppSuccessPage),
BackChannelLogoutURI: gu.Ptr(writeModel.BackChannelLogoutURI),
LoginVersion: gu.Ptr(writeModel.LoginVersion),
LoginBaseURI: gu.Ptr(writeModel.LoginBaseURI),
ObjectRoot: writeModelToObjectRoot(writeModel.WriteModel),
AppID: writeModel.AppID,
AppName: writeModel.AppName,
State: writeModel.State,
ClientID: writeModel.ClientID,
RedirectUris: writeModel.RedirectUris,
ResponseTypes: writeModel.ResponseTypes,
GrantTypes: writeModel.GrantTypes,
ApplicationType: gu.Ptr(writeModel.ApplicationType),
AuthMethodType: gu.Ptr(writeModel.AuthMethodType),
PostLogoutRedirectUris: writeModel.PostLogoutRedirectUris,
OIDCVersion: gu.Ptr(writeModel.OIDCVersion),
DevMode: gu.Ptr(writeModel.DevMode),
AccessTokenType: gu.Ptr(writeModel.AccessTokenType),
AccessTokenRoleAssertion: gu.Ptr(writeModel.AccessTokenRoleAssertion),
IDTokenRoleAssertion: gu.Ptr(writeModel.IDTokenRoleAssertion),
IDTokenUserinfoAssertion: gu.Ptr(writeModel.IDTokenUserinfoAssertion),
ClockSkew: gu.Ptr(writeModel.ClockSkew),
AdditionalOrigins: writeModel.AdditionalOrigins,
SkipNativeAppSuccessPage: gu.Ptr(writeModel.SkipNativeAppSuccessPage),
BackChannelLogoutURI: gu.Ptr(writeModel.BackChannelLogoutURI),
LoginVersion: gu.Ptr(writeModel.LoginVersion),
LoginBaseURI: gu.Ptr(writeModel.LoginBaseURI),
IOSTeamID: emptyStringPtr(writeModel.IOSTeamID),
IOSBundleID: emptyStringPtr(writeModel.IOSBundleID),
AndroidPackageName: emptyStringPtr(writeModel.AndroidPackageName),
AndroidSHA256CertFingerprints: writeModel.AndroidSHA256CertFingerprints,
}
}
// emptyStringPtr returns nil for empty strings so unset app-link fields stay
// distinguishable from explicitly set values in equality checks / APIs.
func emptyStringPtr(s string) *string {
if s == "" {
return nil
}
return gu.Ptr(s)
}
func samlWriteModelToSAMLConfig(writeModel *SAMLApplicationWriteModel) *domain.SAMLApp {
return &domain.SAMLApp{
ObjectRoot: writeModelToObjectRoot(writeModel.WriteModel),