Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions cmd/api/src/api/registration/v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ func NewV2API(resources v2.Resources, routerInst *router.Router) {
routerInst.GET(fmt.Sprintf("/api/v2/gpos/{%s}", api.URIPathVariableObjectID), resources.GetGPOEntityInfo).RequirePermissions(permissions.GraphDBRead),
routerInst.GET(fmt.Sprintf("/api/v2/gpos/{%s}/computers", api.URIPathVariableObjectID), resources.ListADGPOAffectedComputers).RequirePermissions(permissions.GraphDBRead),
routerInst.GET(fmt.Sprintf("/api/v2/gpos/{%s}/users", api.URIPathVariableObjectID), resources.ListADGPOAffectedUsers).RequirePermissions(permissions.GraphDBRead),
routerInst.GET(fmt.Sprintf("/api/v2/gpos/{%s}/sites", api.URIPathVariableObjectID), resources.ListADGPOAffectedSites).RequirePermissions(permissions.GraphDBRead),
routerInst.GET(fmt.Sprintf("/api/v2/gpos/{%s}/controllers", api.URIPathVariableObjectID), resources.ListADEntityControllers).RequirePermissions(permissions.GraphDBRead),
routerInst.GET(fmt.Sprintf("/api/v2/gpos/{%s}/tier-zero", api.URIPathVariableObjectID), resources.ListADGPOAffectedTierZero).RequirePermissions(permissions.GraphDBRead),
routerInst.GET(fmt.Sprintf("/api/v2/gpos/{%s}/ous", api.URIPathVariableObjectID), resources.ListADGPOAffectedContainers).RequirePermissions(permissions.GraphDBRead),
Expand Down Expand Up @@ -347,6 +348,19 @@ func NewV2API(resources v2.Resources, routerInst *router.Router) {
routerInst.GET(fmt.Sprintf("/api/v2/issuancepolicies/{%s}/controllers", api.URIPathVariableObjectID), resources.ListADEntityControllers).RequirePermissions(permissions.GraphDBRead),
routerInst.GET(fmt.Sprintf("/api/v2/issuancepolicies/{%s}/linkedtemplates", api.URIPathVariableObjectID), resources.ListADIssuancePolicyLinkedCertTemplates).RequirePermissions(permissions.GraphDBRead),

// Site Entity API
routerInst.GET(fmt.Sprintf("/api/v2/sites/{%s}", api.URIPathVariableObjectID), resources.GetSiteEntityInfo).RequirePermissions(permissions.GraphDBRead),
routerInst.GET(fmt.Sprintf("/api/v2/sites/{%s}/controllers", api.URIPathVariableObjectID), resources.ListADEntityControllers).RequirePermissions(permissions.GraphDBRead),
routerInst.GET(fmt.Sprintf("/api/v2/sites/{%s}/linked-gpos", api.URIPathVariableObjectID), resources.ListADSiteLinkedGPOs).RequirePermissions(permissions.GraphDBRead),
routerInst.GET(fmt.Sprintf("/api/v2/sites/{%s}/siteservers", api.URIPathVariableObjectID), resources.ListADSiteLinkedServers).RequirePermissions(permissions.GraphDBRead),
routerInst.GET(fmt.Sprintf("/api/v2/sites/{%s}/sitesubnets", api.URIPathVariableObjectID), resources.ListADSiteLinkedSubnets).RequirePermissions(permissions.GraphDBRead),

// SiteServer Entity API
routerInst.GET(fmt.Sprintf("/api/v2/siteservers/{%s}", api.URIPathVariableObjectID), resources.GetSiteServerEntityInfo).RequirePermissions(permissions.GraphDBRead),

// SiteSubnet Entity API
routerInst.GET(fmt.Sprintf("/api/v2/sitesubnets/{%s}", api.URIPathVariableObjectID), resources.GetSiteSubnetEntityInfo).RequirePermissions(permissions.GraphDBRead),

// Data Quality Stats API
routerInst.GET(fmt.Sprintf("/api/v2/ad-domains/{%s}/data-quality-stats", api.URIPathVariableDomainID), resources.GetADDataQualityStats).RequirePermissions(permissions.GraphDBRead).SupportsETAC(resources.DB, resources.DogTags),
routerInst.GET(fmt.Sprintf("/api/v2/azure-tenants/{%s}/data-quality-stats", api.URIPathVariableTenantID), resources.GetAzureDataQualityStats).RequirePermissions(permissions.GraphDBRead).SupportsETAC(resources.DB, resources.DogTags),
Expand Down
65 changes: 64 additions & 1 deletion cmd/api/src/api/v2/ad_entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ type DomainPatchRequest struct {
Collected *bool `json:"collected"`
}

const (
serverReferenceComputerNameProperty = "serverreferencecomputername"
serverReferenceComputerProperty = "serverreferencecomputer"
siteServerNodeNameProperty = "siteservernodename"
siteServerNodeProperty = "siteservernode"
)

func (s *Resources) PatchDomain(response http.ResponseWriter, request *http.Request) {
var domainPatchReq DomainPatchRequest
if err := api.ReadJSONRequestPayloadLimited(&domainPatchReq, request); err != nil {
Expand Down Expand Up @@ -95,13 +102,39 @@ func (s *Resources) handleAdEntityInfoQuery(response http.ResponseWriter, reques
api.WriteBasicResponse(request.Context(), results, http.StatusOK, response)
} else {
if tiering.IsTierZero(node) {
node.Properties.Map["isTierZero"] = true
node.Properties.Set("isTierZero", true)
}

if err := s.addServerIsLinkedProperties(request, node, entityType); err != nil {
api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error getting linked node: %v", err), request), response)
return
}

results := map[string]any{"props": node.Properties.Map, "kinds": node.Kinds.Strings()}
api.WriteBasicResponse(request.Context(), results, http.StatusOK, response)
}
}

func (s *Resources) addServerIsLinkedProperties(request *http.Request, node *graph.Node, entityType graph.Kind) error {
if entityType.Is(ad.SiteServer) {
if linkedComputer, err := s.GraphQuery.GetEntityByRelationship(request.Context(), node, graph.DirectionOutbound, ad.ServerIs, ad.Computer); err != nil {
return err
} else if linkedComputer != nil {
node.Properties.Set(serverReferenceComputerProperty, linkedComputer.Properties.Get(common.ObjectID.String()).Any())
node.Properties.Set(serverReferenceComputerNameProperty, linkedComputer.Properties.Get(common.Name.String()).Any())
}
} else if entityType.Is(ad.Computer) {
if linkedSiteServer, err := s.GraphQuery.GetEntityByRelationship(request.Context(), node, graph.DirectionInbound, ad.ServerIs, ad.SiteServer); err != nil {
return err
} else if linkedSiteServer != nil {
node.Properties.Set(siteServerNodeProperty, linkedSiteServer.Properties.Get(common.ObjectID.String()).Any())
node.Properties.Set(siteServerNodeNameProperty, linkedSiteServer.Properties.Get(common.Name.String()).Any())
}
}

return nil
}

func (s *Resources) GetBaseEntityInfo(response http.ResponseWriter, request *http.Request) {
var (
countQueries = map[string]any{
Expand Down Expand Up @@ -176,6 +209,7 @@ func (s *Resources) GetGPOEntityInfo(response http.ResponseWriter, request *http
"ous": adAnalysis.CreateGPOAffectedIntermediariesListDelegate(adAnalysis.SelectGPOContainerCandidateFilter),
"computers": adAnalysis.CreateGPOAffectedIntermediariesListDelegate(adAnalysis.SelectComputersCandidateFilter),
"users": adAnalysis.CreateGPOAffectedIntermediariesListDelegate(adAnalysis.SelectUsersCandidateFilter),
"sites": adAnalysis.CreateGPOAffectedIntermediariesListDelegate(adAnalysis.SelectSitesCandidateFilter),
"controllers": adAnalysis.FetchInboundADEntityControllers,
"tierzero": adAnalysis.CreateGPOAffectedIntermediariesListDelegate(adAnalysis.SelectGPOTierZeroCandidateFilter),
}
Expand Down Expand Up @@ -298,3 +332,32 @@ func (s *Resources) GetIssuancePolicyEntityInfo(response http.ResponseWriter, re

s.handleAdEntityInfoQuery(response, request, ad.IssuancePolicy, countQueries)
}

func (s *Resources) GetSiteEntityInfo(response http.ResponseWriter, request *http.Request) {
var (
countQueries = map[string]any{
"controllers": adAnalysis.FetchInboundADEntityControllers,
"linkedgpos": adAnalysis.FetchEntityLinkedGPOList,
"siteServers": adAnalysis.CreateSiteContainedListDelegate(ad.SiteServer),
"siteSubnets": adAnalysis.CreateSiteContainedListDelegate(ad.SiteSubnet),
}
)

s.handleAdEntityInfoQuery(response, request, ad.Site, countQueries)
}

func (s *Resources) GetSiteServerEntityInfo(response http.ResponseWriter, request *http.Request) {
var (
countQueries = map[string]any{}
)

s.handleAdEntityInfoQuery(response, request, ad.SiteServer, countQueries)
}

func (s *Resources) GetSiteSubnetEntityInfo(response http.ResponseWriter, request *http.Request) {
var (
countQueries = map[string]any{}
)

s.handleAdEntityInfoQuery(response, request, ad.SiteSubnet, countQueries)
}
96 changes: 96 additions & 0 deletions cmd/api/src/api/v2/ad_entity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import (
"github.com/specterops/bloodhound/cmd/api/src/services/dogtags"
"github.com/specterops/bloodhound/cmd/api/src/utils/test"
"github.com/specterops/bloodhound/packages/go/graphschema/ad"
"github.com/specterops/bloodhound/packages/go/graphschema/common"
"github.com/specterops/bloodhound/packages/go/headers"
"github.com/specterops/bloodhound/packages/go/mediatypes"
"github.com/specterops/dawgs/graph"
Expand Down Expand Up @@ -144,9 +145,104 @@ func TestResources_GetComputerEntityInfo(t *testing.T) {
mockGraph.EXPECT().
GetEntityByObjectId(gomock.Any(), gomock.Any(), gomock.Any()).
Return(graph.NewNode(graph.ID(1), graph.NewProperties()), nil)
mockGraph.EXPECT().
GetEntityByRelationship(gomock.Any(), gomock.Any(), graph.DirectionInbound, ad.ServerIs, ad.SiteServer).
Return(nil, nil)
},
Test: func(output apitest.Output) {
apitest.StatusCode(output, http.StatusOK)
apitest.BodyNotContains(output, "siteservernode")
},
},
{
Name: "SuccessWithoutCountsWithSiteServer",
Input: func(input *apitest.Input) {
apitest.SetURLVar(input, "object_id", "1")
apitest.AddQueryParam(input, "counts", "false")
apitest.SetContext(input, bheCtx.ConstructGoContext())
},
Setup: func() {
siteServerProperties := graph.NewProperties().
Set(common.ObjectID.String(), "SITE-SERVER-1").
Set(common.Name.String(), "SITE-SERVER-NAME")

mockGraph.EXPECT().
GetEntityByObjectId(gomock.Any(), gomock.Any(), gomock.Any()).
Return(graph.NewNode(graph.ID(1), graph.NewProperties().Set(common.ObjectID.String(), "COMPUTER-1")), nil)
mockGraph.EXPECT().
GetEntityByRelationship(gomock.Any(), gomock.Any(), graph.DirectionInbound, ad.ServerIs, ad.SiteServer).
Return(graph.NewNode(graph.ID(2), siteServerProperties, ad.SiteServer), nil)
},
Test: func(output apitest.Output) {
apitest.StatusCode(output, http.StatusOK)
apitest.BodyContains(output, `"siteservernode":"SITE-SERVER-1"`)
apitest.BodyContains(output, `"siteservernodename":"SITE-SERVER-NAME"`)
},
},
})
}

func TestResources_GetSiteServerEntityInfo(t *testing.T) {
var (
mockCtrl = gomock.NewController(t)
mockGraph = mocks.NewMockGraph(mockCtrl)
resources = v2.Resources{GraphQuery: mockGraph, DogTags: dogtags.NewTestService(dogtags.TestOverrides{})}

bheCtx = bhctx.Context{
AuthCtx: auth.Context{
PermissionOverrides: auth.PermissionOverrides{},
Owner: model.User{},
Session: model.UserSession{},
},
}
)
defer mockCtrl.Finish()

apitest.NewHarness(t, resources.GetSiteServerEntityInfo).
Run([]apitest.Case{
{
Name: "SuccessWithoutCounts",
Input: func(input *apitest.Input) {
apitest.SetURLVar(input, "object_id", "1")
apitest.AddQueryParam(input, "counts", "false")
apitest.SetContext(input, bheCtx.ConstructGoContext())
},
Setup: func() {
mockGraph.EXPECT().
GetEntityByObjectId(gomock.Any(), gomock.Any(), gomock.Any()).
Return(graph.NewNode(graph.ID(1), graph.NewProperties()), nil)
mockGraph.EXPECT().
GetEntityByRelationship(gomock.Any(), gomock.Any(), graph.DirectionOutbound, ad.ServerIs, ad.Computer).
Return(nil, nil)
},
Test: func(output apitest.Output) {
apitest.StatusCode(output, http.StatusOK)
apitest.BodyNotContains(output, "serverreferencecomputer")
},
},
{
Name: "SuccessWithoutCountsWithServerReference",
Input: func(input *apitest.Input) {
apitest.SetURLVar(input, "object_id", "1")
apitest.AddQueryParam(input, "counts", "false")
apitest.SetContext(input, bheCtx.ConstructGoContext())
},
Setup: func() {
computerProperties := graph.NewProperties().
Set(common.ObjectID.String(), "COMPUTER-1").
Set(common.Name.String(), "COMPUTER-NAME")

mockGraph.EXPECT().
GetEntityByObjectId(gomock.Any(), gomock.Any(), gomock.Any()).
Return(graph.NewNode(graph.ID(1), graph.NewProperties().Set(common.ObjectID.String(), "SITE-SERVER-1")), nil)
mockGraph.EXPECT().
GetEntityByRelationship(gomock.Any(), gomock.Any(), graph.DirectionOutbound, ad.ServerIs, ad.Computer).
Return(graph.NewNode(graph.ID(2), computerProperties, ad.Computer), nil)
},
Test: func(output apitest.Output) {
apitest.StatusCode(output, http.StatusOK)
apitest.BodyContains(output, `"serverreferencecomputer":"COMPUTER-1"`)
apitest.BodyContains(output, `"serverreferencecomputername":"COMPUTER-NAME"`)
},
},
})
Expand Down
16 changes: 16 additions & 0 deletions cmd/api/src/api/v2/ad_related_entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,10 @@ func (s *Resources) ListADGPOAffectedUsers(response http.ResponseWriter, request
s.handleAdRelatedEntityQuery(response, request, "ListADGPOAffectedUsers", adAnalysis.CreateGPOAffectedIntermediariesPathDelegate(ad.User), adAnalysis.CreateGPOAffectedIntermediariesListDelegate(adAnalysis.SelectUsersCandidateFilter))
}

func (s *Resources) ListADGPOAffectedSites(response http.ResponseWriter, request *http.Request) {
s.handleAdRelatedEntityQuery(response, request, "ListADGPOAffectedSites", adAnalysis.FetchGPOAffectedSitePaths, adAnalysis.FetchGPOAffectedSites)
}

func (s *Resources) ListADGPOAffectedComputers(response http.ResponseWriter, request *http.Request) {
s.handleAdRelatedEntityQuery(response, request, "ListADGPOAffectedComputers", adAnalysis.CreateGPOAffectedIntermediariesPathDelegate(ad.Computer), adAnalysis.CreateGPOAffectedIntermediariesListDelegate(adAnalysis.SelectComputersCandidateFilter))
}
Expand Down Expand Up @@ -260,3 +264,15 @@ func (s *Resources) ListTrustedCAs(response http.ResponseWriter, request *http.R
func (s *Resources) ListADCSEscalations(response http.ResponseWriter, request *http.Request) {
s.handleAdRelatedEntityQuery(response, request, "ListADCSEscalations", adAnalysis.CreateADCSEscalationsPathDelegate, adAnalysis.CreateADCSEscalationsListDelegate)
}

func (s *Resources) ListADSiteLinkedServers(response http.ResponseWriter, request *http.Request) {
s.handleAdRelatedEntityQuery(response, request, "ListADSiteLinkedServers", adAnalysis.CreateSiteContainedPathDelegate(ad.SiteServer), adAnalysis.CreateSiteContainedListDelegate(ad.SiteServer))
}

func (s *Resources) ListADSiteLinkedGPOs(response http.ResponseWriter, request *http.Request) {
s.handleAdRelatedEntityQuery(response, request, "ListADSiteLinkedGPOs", adAnalysis.FetchEntityLinkedGPOPaths, adAnalysis.FetchEntityLinkedGPOList)
}

func (s *Resources) ListADSiteLinkedSubnets(response http.ResponseWriter, request *http.Request) {
s.handleAdRelatedEntityQuery(response, request, "ListADSiteLinkedSubnets", adAnalysis.CreateSiteContainedPathDelegate(ad.SiteSubnet), adAnalysis.CreateSiteContainedListDelegate(ad.SiteSubnet))
}
8 changes: 8 additions & 0 deletions cmd/api/src/api/v2/ad_related_entity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,14 @@ func TestResources_ListADCSEscalations(t *testing.T) {
Run(setupCases(mockGraph, mockDB))
}

func TestResources_ListADSiteLinkedGPOs(t *testing.T) {
var mockCtrl, mockGraph, mockDB, resources = setup(t)
defer mockCtrl.Finish()

apitest.NewHarness(t, resources.ListADSiteLinkedGPOs).
Run(setupCases(mockGraph, mockDB))
}

func TestResources_ListADIssuancePolicyLinkedCertTemplates(t *testing.T) {
t.Parallel()

Expand Down
6 changes: 6 additions & 0 deletions cmd/api/src/database/dataquality.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ WITH aggregated_quality_stats AS (
MAX(ntauthstores) AS max_ntauthstores,
MAX(certtemplates) AS max_certtemplates,
MAX(issuancepolicies) AS max_issuancepolicies,
MAX(sites) AS max_sites,
MAX(siteservers) AS max_siteservers,
MAX(sitesubnets) AS max_sitesubnets,
MAX(session_completeness) AS max_session_completeness,
MAX(local_group_completeness) AS max_local_group_completeness
FROM ad_data_quality_stats
Expand All @@ -121,6 +124,9 @@ SELECT
SUM(max_ntauthstores) AS ntauthstores,
SUM(max_certtemplates) AS certtemplates,
SUM(max_issuancepolicies) AS issuancepolicies,
SUM(max_sites) AS sites,
SUM(max_siteservers) AS siteservers,
SUM(max_sitesubnets) AS sitesubnets,
MAX(max_session_completeness) AS session_completeness,
MAX(max_local_group_completeness) AS local_group_completeness
FROM aggregated_quality_stats
Expand Down
15 changes: 13 additions & 2 deletions cmd/api/src/database/migration/extensions/ad_graph_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ BEGIN

RETURN kind_id;
END $$ LANGUAGE plpgsql;

CREATE OR REPLACE FUNCTION genscript_upsert_source_kind(kind_name TEXT) RETURNS SMALLINT AS $$
DECLARE
retrieved_kind_id SMALLINT;
Expand All @@ -45,7 +45,7 @@ BEGIN

RETURN source_kind_id;
END $$ LANGUAGE plpgsql;

CREATE OR REPLACE FUNCTION genscript_upsert_schema_node_kind(v_extension_id INT, v_kind_name VARCHAR(256), v_display_name TEXT, v_description TEXT, v_is_display_kind BOOLEAN, v_icon TEXT, v_icon_color TEXT) RETURNS void AS $$
DECLARE
retrieved_kind_id SMALLINT;
Expand Down Expand Up @@ -171,6 +171,9 @@ BEGIN
PERFORM genscript_upsert_kind('NTAuthStore');
PERFORM genscript_upsert_kind('CertTemplate');
PERFORM genscript_upsert_kind('IssuancePolicy');
PERFORM genscript_upsert_kind('Site');
PERFORM genscript_upsert_kind('SiteServer');
PERFORM genscript_upsert_kind('SiteSubnet');

-- Insert Relationship Kinds
PERFORM genscript_upsert_kind('Owns');
Expand All @@ -184,6 +187,7 @@ BEGIN
PERFORM genscript_upsert_kind('AddMember');
PERFORM genscript_upsert_kind('HasSession');
PERFORM genscript_upsert_kind('Contains');
PERFORM genscript_upsert_kind('ServerIs');
PERFORM genscript_upsert_kind('GPLink');
PERFORM genscript_upsert_kind('AllowedToDelegate');
PERFORM genscript_upsert_kind('CoerceToTGT');
Expand Down Expand Up @@ -278,6 +282,9 @@ BEGIN
PERFORM genscript_upsert_schema_node_kind(extension_id, 'NTAuthStore', 'NTAuthStore', '', true, 'store', '#D575F5');
PERFORM genscript_upsert_schema_node_kind(extension_id, 'CertTemplate', 'CertTemplate', '', true, 'id-card', '#B153F3');
PERFORM genscript_upsert_schema_node_kind(extension_id, 'IssuancePolicy', 'IssuancePolicy', '', true, 'clipboard-check', '#99B2DD');
PERFORM genscript_upsert_schema_node_kind(extension_id, 'Site', 'Site', '', true, 'map-signs', '#2DD4BF');
PERFORM genscript_upsert_schema_node_kind(extension_id, 'SiteServer', 'SiteServer', '', true, 'map-marker', '#60A5FA');
PERFORM genscript_upsert_schema_node_kind(extension_id, 'SiteSubnet', 'SiteSubnet', '', true, 'map', '#F59E0B');

-- Keep custom_node_kinds in sync with node kinds
PERFORM genscript_upsert_custom_node_kind('User', '{"icon": {"name": "user", "type": "font-awesome", "color": "#17E625"}}');
Expand All @@ -293,6 +300,9 @@ BEGIN
PERFORM genscript_upsert_custom_node_kind('NTAuthStore', '{"icon": {"name": "store", "type": "font-awesome", "color": "#D575F5"}}');
PERFORM genscript_upsert_custom_node_kind('CertTemplate', '{"icon": {"name": "id-card", "type": "font-awesome", "color": "#B153F3"}}');
PERFORM genscript_upsert_custom_node_kind('IssuancePolicy', '{"icon": {"name": "clipboard-check", "type": "font-awesome", "color": "#99B2DD"}}');
PERFORM genscript_upsert_custom_node_kind('Site', '{"icon": {"name": "map-signs", "type": "font-awesome", "color": "#2DD4BF"}}');
PERFORM genscript_upsert_custom_node_kind('SiteServer', '{"icon": {"name": "map-marker", "type": "font-awesome", "color": "#60A5FA"}}');
PERFORM genscript_upsert_custom_node_kind('SiteSubnet', '{"icon": {"name": "map", "type": "font-awesome", "color": "#F59E0B"}}');

PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'Owns', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'GenericAll', '', true);
Expand All @@ -305,6 +315,7 @@ BEGIN
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AddMember', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'HasSession', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'Contains', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'ServerIs', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'GPLink', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AllowedToDelegate', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'CoerceToTGT', '', true);
Expand Down
Loading
Loading