Skip to content

Commit 74e4f15

Browse files
committed
fix(postprocess): emit ADCSESCx edges to other forests
1 parent 54d5fbf commit 74e4f15

3 files changed

Lines changed: 58 additions & 76 deletions

File tree

packages/go/analysis/ad/adcs_forest_integration_test.go

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,15 @@ import (
3030
"github.com/stretchr/testify/require"
3131
)
3232

33-
// addEnabledHostingComputer creates an enabled computer in the given domain and
34-
// links it to the CA via HostsCAService (mirrors the integration package's
35-
// unexported addHostingComputer).
36-
func addEnabledHostingComputer(testCtx *integration.GraphTestContext, name, domainSID string, enterpriseCA *graph.Node) {
33+
// addEnabledHostingComputer creates an enabled computer in the given domain,
34+
// links it to the CA via HostsCAService, and returns the computer for tests that
35+
// need to model one host serving multiple EnterpriseCA nodes.
36+
func addEnabledHostingComputer(testCtx *integration.GraphTestContext, name, domainSID string, enterpriseCA *graph.Node) *graph.Node {
3737
computer := testCtx.NewActiveDirectoryComputer(name, domainSID)
3838
computer.Properties.Set(common.Enabled.String(), true)
3939
testCtx.UpdateNode(computer)
4040
testCtx.NewRelationship(computer, enterpriseCA, ad.HostsCAService)
41+
return computer
4142
}
4243

4344
// linkEnterpriseCAToDomain adds the per-domain edges that make a domain "chain
@@ -55,43 +56,47 @@ func linkEnterpriseCAToDomain(testCtx *integration.GraphTestContext, enterpriseC
5556
testCtx.NewRelationship(enterpriseCA, ntAuthStore, ad.TrustedForNTAuth)
5657
}
5758

58-
// TestADCSForestScoping_FiltersForeignForestDomain models shared ADCS across two
59-
// forests: a CA hosted in forest A whose cert chain also reaches forest B's
60-
// domain. The CA's hosting computer is in forest A, so the CA is retained but its
61-
// chained-domain set must be scoped to forest A only.
62-
func TestADCSForestScoping_FiltersForeignForestDomain(t *testing.T) {
59+
// TestADCSForestScoping_UsesHostForestECAAndKeepsCrossForestDomain models
60+
// shared ADCS across two forests: one computer hosts the CA service for an
61+
// EnterpriseCA in its own forest and for a copied EnterpriseCA in another forest.
62+
// Only the host-forest EnterpriseCA should be retained, and it should still chain
63+
// to every domain reached by RootCAFor and TrustedForNTAuth.
64+
func TestADCSForestScoping_UsesHostForestECAAndKeepsCrossForestDomain(t *testing.T) {
6365
testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
6466

6567
var (
6668
domainASID = integration.RandomDomainSID()
6769
domainBSID = integration.RandomDomainSID()
6870

69-
enterpriseCAID graph.ID
70-
domainAID graph.ID
71-
domainBID graph.ID
71+
hostForestEnterpriseCAID graph.ID
72+
copiedEnterpriseCAID graph.ID
73+
domainAID graph.ID
74+
domainBID graph.ID
7275
)
7376

7477
testContext.DatabaseTestWithSetup(
7578
func(harness *integration.HarnessDetails) error {
7679
domainA := testContext.NewActiveDirectoryDomain("ForestA-Domain", domainASID, false, true)
7780
domainB := testContext.NewActiveDirectoryDomain("ForestB-Domain", domainBSID, false, true)
7881

79-
// The CA and its root live in forest A.
80-
enterpriseCA := testContext.NewActiveDirectoryEnterpriseCA("SharedECA", domainASID)
82+
// The real CA lives in forest A; a copied EnterpriseCA object exists in
83+
// forest B and points at the same hosting computer.
84+
hostForestEnterpriseCA := testContext.NewActiveDirectoryEnterpriseCA("SharedECA", domainASID)
85+
copiedEnterpriseCA := testContext.NewActiveDirectoryEnterpriseCA("SharedECA-Copy", domainBSID)
8186
rootCA := testContext.NewActiveDirectoryRootCA("SharedRootCA", domainASID)
8287

83-
// The CA chains up to its root once; the per-domain edges are added below.
84-
testContext.NewRelationship(enterpriseCA, rootCA, ad.EnterpriseCAFor)
88+
testContext.NewRelationship(hostForestEnterpriseCA, rootCA, ad.EnterpriseCAFor)
8589

8690
// Valid cert chain to forest A (the CA's own forest)...
87-
linkEnterpriseCAToDomain(testContext, enterpriseCA, rootCA, domainA, domainASID)
91+
linkEnterpriseCAToDomain(testContext, hostForestEnterpriseCA, rootCA, domainA, domainASID)
8892
// ...and a cross-forest chain into forest B (shared ADCS).
89-
linkEnterpriseCAToDomain(testContext, enterpriseCA, rootCA, domainB, domainBSID)
93+
linkEnterpriseCAToDomain(testContext, hostForestEnterpriseCA, rootCA, domainB, domainBSID)
9094

91-
// Hosting computer lives in the CA's own forest.
92-
addEnabledHostingComputer(testContext, "HostA", domainASID, enterpriseCA)
95+
host := addEnabledHostingComputer(testContext, "HostA", domainASID, hostForestEnterpriseCA)
96+
testContext.NewRelationship(host, copiedEnterpriseCA, ad.HostsCAService)
9397

94-
enterpriseCAID = enterpriseCA.ID
98+
hostForestEnterpriseCAID = hostForestEnterpriseCA.ID
99+
copiedEnterpriseCAID = copiedEnterpriseCA.ID
95100
domainAID = domainA.ID
96101
domainBID = domainB.ID
97102
return nil
@@ -102,10 +107,11 @@ func TestADCSForestScoping_FiltersForeignForestDomain(t *testing.T) {
102107

103108
chainedDomains := cache.GetECAHostedChainedDomains()
104109

105-
require.Contains(t, chainedDomains, enterpriseCAID.Uint64(), "CA with an in-forest host should be retained")
106-
chains := chainedDomains[enterpriseCAID.Uint64()]
110+
require.Contains(t, chainedDomains, hostForestEnterpriseCAID.Uint64(), "CA with an in-forest host should be retained")
111+
assert.NotContains(t, chainedDomains, copiedEnterpriseCAID.Uint64(), "copied CA with only a cross-forest host should be skipped")
112+
chains := chainedDomains[hostForestEnterpriseCAID.Uint64()]
107113
assert.True(t, chains.Domains.Contains(domainAID.Uint64()), "in-forest domain should survive")
108-
assert.False(t, chains.Domains.Contains(domainBID.Uint64()), "foreign-forest domain should be filtered out")
114+
assert.True(t, chains.Domains.Contains(domainBID.Uint64()), "cross-forest chained domain should survive")
109115
},
110116
)
111117
}

packages/go/analysis/ad/adcscache.go

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,6 @@ type ADCSCache struct {
139139
authStoreForChainValid map[graph.ID]cardinality.Duplex[uint64] //Auth stores with a valid chain to the domain, key is domain ID
140140
rootCAForChainValid map[graph.ID]cardinality.Duplex[uint64] //Root CA with a valid chain to the domain, key is domain ID
141141
hasHostingComputer map[graph.ID]bool
142-
ecaForestDomains map[graph.ID]cardinality.Duplex[uint64] // enterprise CA ID -> domain IDs in the CA's own forest (SameForestTrust closure). Absent when the forest can't be resolved, which disables forest filtering for that CA.
143142
hasInForestHostingComputer map[graph.ID]bool // enterprise CA ID -> whether the CA has an enabled hosting computer inside its own forest
144143

145144
// ESC4-specific caches: principals with specific rights on cert templates, pre-computed to avoid per-ECA DB queries
@@ -161,7 +160,6 @@ func NewADCSCache() *ADCSCache {
161160
authStoreForChainValid: make(map[graph.ID]cardinality.Duplex[uint64]),
162161
rootCAForChainValid: make(map[graph.ID]cardinality.Duplex[uint64]),
163162
hasHostingComputer: make(map[graph.ID]bool),
164-
ecaForestDomains: make(map[graph.ID]cardinality.Duplex[uint64]),
165163
hasInForestHostingComputer: make(map[graph.ID]bool),
166164
certTemplateEnrollers: make(map[graph.ID]CachedPrincipalSet),
167165
certTemplateControllers: make(map[graph.ID]CachedPrincipalSet),
@@ -380,7 +378,6 @@ func (s *ADCSCache) BuildCache(ctx context.Context, db graph.Database, enterpris
380378

381379
s.hasHostingComputer[eca.ID] = hasHostingComputer
382380
if forestKnown {
383-
s.ecaForestDomains[eca.ID] = forestDomains
384381
s.hasInForestHostingComputer[eca.ID] = hostInForest
385382
}
386383
}
@@ -536,12 +533,10 @@ func (s *ADCSCache) GetECAHostedChainedDomains() map[uint64]*EnterpriseCAChained
536533
for _, enterpriseCA := range s.enterpriseCertAuthorities {
537534
innerEnterpriseCA := enterpriseCA
538535

539-
forestDomains, forestKnown := s.ecaForestDomains[innerEnterpriseCA.ID]
540-
541536
// Require an enabled hosting computer; when the forest is known, require it
542537
// in-forest. Drops CAs whose only host was matched across a forest boundary.
543-
if forestKnown {
544-
if !s.hasInForestHostingComputer[innerEnterpriseCA.ID] {
538+
if hasInForestHostingComputer, forestKnown := s.hasInForestHostingComputer[innerEnterpriseCA.ID]; forestKnown {
539+
if !hasInForestHostingComputer {
545540
continue
546541
}
547542
} else if !s.hasHostingComputer[innerEnterpriseCA.ID] {
@@ -552,12 +547,6 @@ func (s *ADCSCache) GetECAHostedChainedDomains() map[uint64]*EnterpriseCAChained
552547
for _, domain := range s.domains {
553548
innerDomain := domain
554549

555-
// Skip domains outside the CA's forest so the ESC fan-out never reaches
556-
// a foreign-forest domain.
557-
if forestKnown && !forestDomains.Contains(innerDomain.ID.Uint64()) {
558-
continue
559-
}
560-
561550
if _, ok := s.rootCAForChainValid[innerDomain.ID]; !ok {
562551
continue
563552
} else if _, ok := s.authStoreForChainValid[innerDomain.ID]; !ok {
@@ -584,18 +573,10 @@ func (s *ADCSCache) GetChainedDomains() map[uint64]*EnterpriseCAChainedDomains {
584573
for _, enterpriseCA := range s.enterpriseCertAuthorities {
585574
innerEnterpriseCA := enterpriseCA
586575

587-
forestDomains, forestKnown := s.ecaForestDomains[innerEnterpriseCA.ID]
588-
589576
targetDomains := NewEnterpriseCAChainedDomains(enterpriseCA)
590577
for _, domain := range s.domains {
591578
innerDomain := domain
592579

593-
// Skip domains outside the CA's forest, keeping EnrollOnBehalfOf linkage
594-
// from crossing a forest boundary.
595-
if forestKnown && !forestDomains.Contains(innerDomain.ID.Uint64()) {
596-
continue
597-
}
598-
599580
if _, ok := s.rootCAForChainValid[innerDomain.ID]; !ok {
600581
continue
601582
} else if _, ok := s.authStoreForChainValid[innerDomain.ID]; !ok {

packages/go/analysis/ad/adcscache_test.go

Lines changed: 26 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ func duplexOf(ids ...graph.ID) cardinality.Duplex[uint64] {
3434
return bitmap
3535
}
3636

37-
// newForestFilterCache builds an ADCSCache where the CA has a valid cert chain to
38-
// both domains, so only forest scoping can distinguish them. Each test sets the
39-
// forest-scoping fields itself.
40-
func newForestFilterCache(eca, inForestDomain, foreignDomain *graph.Node) *ADCSCache {
37+
// newForestHostingCache builds an ADCSCache where the CA has a valid cert chain
38+
// to both domains, so only the hosting-computer gate can distinguish whether the
39+
// CA should be processed.
40+
func newForestHostingCache(eca, inForestDomain, foreignDomain *graph.Node) *ADCSCache {
4141
cache := NewADCSCache()
4242

4343
cache.enterpriseCertAuthorities = []*graph.Node{eca}
@@ -51,50 +51,48 @@ func newForestFilterCache(eca, inForestDomain, foreignDomain *graph.Node) *ADCSC
5151
return cache
5252
}
5353

54-
func adcsForestFilterNodes() (eca, inForestDomain, foreignDomain *graph.Node) {
54+
func adcsForestHostingNodes() (eca, inForestDomain, foreignDomain *graph.Node) {
5555
eca = graph.NewNode(10, graph.NewProperties(), ad.EnterpriseCA)
5656
inForestDomain = graph.NewNode(1, graph.NewProperties(), ad.Domain)
5757
foreignDomain = graph.NewNode(2, graph.NewProperties(), ad.Domain)
5858
return eca, inForestDomain, foreignDomain
5959
}
6060

61-
func TestGetECAHostedChainedDomains_ForestScoping(t *testing.T) {
62-
t.Run("forest known: keeps in-forest domain, drops foreign-forest domain", func(t *testing.T) {
63-
eca, inForestDomain, foreignDomain := adcsForestFilterNodes()
64-
cache := newForestFilterCache(eca, inForestDomain, foreignDomain)
61+
func TestGetECAHostedChainedDomains_ForestHosting(t *testing.T) {
62+
t.Run("forest known and host in forest: keeps all chained domains", func(t *testing.T) {
63+
eca, inForestDomain, foreignDomain := adcsForestHostingNodes()
64+
cache := newForestHostingCache(eca, inForestDomain, foreignDomain)
6565

6666
cache.hasHostingComputer[eca.ID] = true
6767
cache.hasInForestHostingComputer[eca.ID] = true
68-
cache.ecaForestDomains[eca.ID] = duplexOf(inForestDomain.ID)
6968

7069
result := cache.GetECAHostedChainedDomains()
7170

7271
require.Contains(t, result, eca.ID.Uint64())
7372
chains := result[eca.ID.Uint64()]
7473
assert.True(t, chains.Domains.Contains(inForestDomain.ID.Uint64()), "in-forest domain should survive")
75-
assert.False(t, chains.Domains.Contains(foreignDomain.ID.Uint64()), "foreign-forest domain should be filtered out")
76-
assert.Equal(t, uint64(1), chains.Domains.Cardinality())
74+
assert.True(t, chains.Domains.Contains(foreignDomain.ID.Uint64()), "foreign-forest chained domain should survive")
75+
assert.Equal(t, uint64(2), chains.Domains.Cardinality())
7776
})
7877

7978
t.Run("forest known but only a cross-forest hosting computer: CA is dropped entirely", func(t *testing.T) {
80-
eca, inForestDomain, foreignDomain := adcsForestFilterNodes()
81-
cache := newForestFilterCache(eca, inForestDomain, foreignDomain)
79+
eca, inForestDomain, foreignDomain := adcsForestHostingNodes()
80+
cache := newForestHostingCache(eca, inForestDomain, foreignDomain)
8281

8382
// A hosting computer exists, but it lives outside the CA's forest.
8483
cache.hasHostingComputer[eca.ID] = true
8584
cache.hasInForestHostingComputer[eca.ID] = false
86-
cache.ecaForestDomains[eca.ID] = duplexOf(inForestDomain.ID)
8785

8886
result := cache.GetECAHostedChainedDomains()
8987

9088
assert.NotContains(t, result, eca.ID.Uint64(), "CA with no in-forest host should be skipped")
9189
})
9290

9391
t.Run("forest unknown: falls back to host-only gating with no domain filtering", func(t *testing.T) {
94-
eca, inForestDomain, foreignDomain := adcsForestFilterNodes()
95-
cache := newForestFilterCache(eca, inForestDomain, foreignDomain)
92+
eca, inForestDomain, foreignDomain := adcsForestHostingNodes()
93+
cache := newForestHostingCache(eca, inForestDomain, foreignDomain)
9694

97-
// No ecaForestDomains / hasInForestHostingComputer entry => forest unknown.
95+
// No hasInForestHostingComputer entry => forest unknown.
9896
cache.hasHostingComputer[eca.ID] = true
9997

10098
result := cache.GetECAHostedChainedDomains()
@@ -107,8 +105,8 @@ func TestGetECAHostedChainedDomains_ForestScoping(t *testing.T) {
107105
})
108106

109107
t.Run("forest unknown and no hosting computer: CA is dropped", func(t *testing.T) {
110-
eca, inForestDomain, foreignDomain := adcsForestFilterNodes()
111-
cache := newForestFilterCache(eca, inForestDomain, foreignDomain)
108+
eca, inForestDomain, foreignDomain := adcsForestHostingNodes()
109+
cache := newForestHostingCache(eca, inForestDomain, foreignDomain)
112110

113111
// hasHostingComputer absent/false and forest unknown.
114112

@@ -118,26 +116,23 @@ func TestGetECAHostedChainedDomains_ForestScoping(t *testing.T) {
118116
})
119117
}
120118

121-
func TestGetChainedDomains_ForestScoping(t *testing.T) {
122-
t.Run("forest filter applies without the hosting-computer guard", func(t *testing.T) {
123-
eca, inForestDomain, foreignDomain := adcsForestFilterNodes()
124-
cache := newForestFilterCache(eca, inForestDomain, foreignDomain)
125-
126-
// GetChainedDomains intentionally ignores hosting computers, so leave them
127-
// unset to prove the forest filter alone scopes the EnrollOnBehalfOf linkage.
128-
cache.ecaForestDomains[eca.ID] = duplexOf(inForestDomain.ID)
119+
func TestGetChainedDomains_IgnoresForestHosting(t *testing.T) {
120+
t.Run("does not apply the hosting-computer guard", func(t *testing.T) {
121+
eca, inForestDomain, foreignDomain := adcsForestHostingNodes()
122+
cache := newForestHostingCache(eca, inForestDomain, foreignDomain)
123+
cache.hasInForestHostingComputer[eca.ID] = false
129124

130125
result := cache.GetChainedDomains()
131126

132127
require.Contains(t, result, eca.ID.Uint64())
133128
chains := result[eca.ID.Uint64()]
134129
assert.True(t, chains.Domains.Contains(inForestDomain.ID.Uint64()))
135-
assert.False(t, chains.Domains.Contains(foreignDomain.ID.Uint64()), "foreign-forest domain should be filtered out")
130+
assert.True(t, chains.Domains.Contains(foreignDomain.ID.Uint64()))
136131
})
137132

138133
t.Run("forest unknown: keeps every chained domain", func(t *testing.T) {
139-
eca, inForestDomain, foreignDomain := adcsForestFilterNodes()
140-
cache := newForestFilterCache(eca, inForestDomain, foreignDomain)
134+
eca, inForestDomain, foreignDomain := adcsForestHostingNodes()
135+
cache := newForestHostingCache(eca, inForestDomain, foreignDomain)
141136

142137
result := cache.GetChainedDomains()
143138

0 commit comments

Comments
 (0)