diff --git a/cmd/api/src/bootstrap/server.go b/cmd/api/src/bootstrap/server.go index 6a081bec40d..ca94d0404c5 100644 --- a/cmd/api/src/bootstrap/server.go +++ b/cmd/api/src/bootstrap/server.go @@ -72,6 +72,14 @@ func MigrateDB(ctx context.Context, cfg config.Configuration, db database.Databa return CreateDefaultAdmin(ctx, cfg, db, defaultAdminFunc) } +func PopulateExtensionData(ctx context.Context, db database.Database) error { + if err := db.PopulateExtensionData(ctx); err != nil { + return err + } + + return nil +} + func CreateDefaultAdmin(ctx context.Context, cfg config.Configuration, db database.Database, defaultAdminFunction func() (config.DefaultAdminConfiguration, error)) error { var ( secretDigester = cfg.Crypto.Argon2.NewDigester() diff --git a/cmd/api/src/daemons/changelog/ingestion_integration_test.go b/cmd/api/src/daemons/changelog/ingestion_integration_test.go index 98a697c3ada..dd8067cc712 100644 --- a/cmd/api/src/daemons/changelog/ingestion_integration_test.go +++ b/cmd/api/src/daemons/changelog/ingestion_integration_test.go @@ -116,6 +116,7 @@ func setupIntegrationTest(t *testing.T) IntegrationTestSuite { db := database.NewBloodhoundDB(gormDB, auth.NewIdentityResolver()) require.NoError(t, db.Migrate(ctx)) + require.NoError(t, db.PopulateExtensionData(ctx)) return IntegrationTestSuite{ Context: ctx, diff --git a/cmd/api/src/daemons/datapipe/datapipe_integration_test.go b/cmd/api/src/daemons/datapipe/datapipe_integration_test.go index 6a1fd923d47..68641f87884 100644 --- a/cmd/api/src/daemons/datapipe/datapipe_integration_test.go +++ b/cmd/api/src/daemons/datapipe/datapipe_integration_test.go @@ -93,6 +93,9 @@ func setupIntegrationTestSuite(t *testing.T, fixturesPath string) IntegrationTes err = graphDB.AssertSchema(ctx, graphschema.DefaultGraphSchema()) require.NoError(t, err) + err = db.PopulateExtensionData(ctx) + require.NoError(t, err) + ingestSchema, err := upload.LoadIngestSchema() require.NoError(t, err) diff --git a/cmd/api/src/database/database_integration_test.go b/cmd/api/src/database/database_integration_test.go index d3f7522ce7d..e675dc1ea22 100644 --- a/cmd/api/src/database/database_integration_test.go +++ b/cmd/api/src/database/database_integration_test.go @@ -59,6 +59,9 @@ func setupIntegrationTestSuite(t *testing.T) IntegrationTestSuite { err = db.Migrate(ctx) require.NoError(t, err) + err = db.PopulateExtensionData(ctx) + require.NoError(t, err) + // #endregion return IntegrationTestSuite{ diff --git a/cmd/api/src/database/db.go b/cmd/api/src/database/db.go index 7f776d595f8..b73ee02951e 100644 --- a/cmd/api/src/database/db.go +++ b/cmd/api/src/database/db.go @@ -34,6 +34,7 @@ import ( "github.com/specterops/bloodhound/cmd/api/src/services/agi" "github.com/specterops/bloodhound/cmd/api/src/services/dataquality" "github.com/specterops/bloodhound/cmd/api/src/services/upload" + "github.com/specterops/bloodhound/packages/go/bhlog/attr" "gorm.io/driver/postgres" "gorm.io/gorm" ) @@ -99,6 +100,7 @@ type Database interface { Wipe(ctx context.Context) error Migrate(ctx context.Context) error + PopulateExtensionData(ctx context.Context) error CreateInstallation(ctx context.Context) (model.Installation, error) GetInstallation(ctx context.Context) (model.Installation, error) HasInstallation(ctx context.Context) (bool, error) @@ -280,7 +282,16 @@ func (s *BloodhoundDB) Wipe(ctx context.Context) error { func (s *BloodhoundDB) Migrate(ctx context.Context) error { // Run the migrator if err := migration.NewMigrator(s.db.WithContext(ctx)).ExecuteStepwiseMigrations(); err != nil { - slog.ErrorContext(ctx, fmt.Sprintf("Error during SQL database migration phase: %v", err)) + slog.ErrorContext(ctx, "Error during SQL database migration phase", attr.Error(err)) + return err + } + + return nil +} + +func (s *BloodhoundDB) PopulateExtensionData(ctx context.Context) error { + if err := migration.NewMigrator(s.db.WithContext(ctx)).ExecuteExtensionDataPopulation(); err != nil { + slog.ErrorContext(ctx, "Error during extensions data population phase", attr.Error(err)) return err } diff --git a/cmd/api/src/database/graphschema.go b/cmd/api/src/database/graphschema.go index 03d5e530143..6985aa6661a 100644 --- a/cmd/api/src/database/graphschema.go +++ b/cmd/api/src/database/graphschema.go @@ -223,7 +223,7 @@ func (s *BloodhoundDB) CreateGraphSchemaNodeKind(ctx context.Context, name strin if strings.Contains(result.Error.Error(), DuplicateKeyValueErrorString) { return model.GraphSchemaNodeKind{}, fmt.Errorf("%w: %v", ErrDuplicateSchemaNodeKindName, result.Error) } - return model.GraphSchemaNodeKind{}, CheckError(result) + return model.GraphSchemaNodeKind{}, result.Error } return schemaNodeKind, nil } @@ -232,11 +232,38 @@ func (s *BloodhoundDB) CreateGraphSchemaNodeKind(ctx context.Context, name strin // populated with data, as well as an integer indicating the total number of rows returned by the query (excluding any given pagination). func (s *BloodhoundDB) GetGraphSchemaNodeKinds(ctx context.Context, filters model.Filters, sort model.Sort, skip, limit int) (model.GraphSchemaNodeKinds, int, error) { var ( - schemaNodeKinds = model.GraphSchemaNodeKinds{} - totalRowCount int + schemaNodeKinds = model.GraphSchemaNodeKinds{} + totalRowCount int + tableIdentifiedFilters = make(model.Filters, len(filters)) + tableIdentifiedSort = make(model.Sort, len(sort)) ) - if filterAndPagination, err := parseFiltersAndPagination(filters, sort, skip, limit); err != nil { + // add table identifiers to filtering and sorting columns ensuring we don't return an ambiguous column error + for column, filter := range filters { + if column == "name" { + tableIdentifiedFilters[fmt.Sprintf("%s.%s", "k", column)] = filter + delete(filters, column) + } else { + tableIdentifiedFilters[fmt.Sprintf("%s.%s", "nk", column)] = filter + delete(filters, column) + } + } + + for idx, sortItem := range sort { + if sort[idx].Column == "name" { + tableIdentifiedSort[idx] = model.SortItem{ + Direction: sortItem.Direction, + Column: fmt.Sprintf("%s.%s", "k", sort[idx].Column), + } + } else { + tableIdentifiedSort[idx] = model.SortItem{ + Direction: sortItem.Direction, + Column: fmt.Sprintf("%s.%s", "nk", sort[idx].Column), + } + } + } + + if filterAndPagination, err := parseFiltersAndPagination(tableIdentifiedFilters, tableIdentifiedSort, skip, limit); err != nil { return schemaNodeKinds, 0, err } else { sqlStr := fmt.Sprintf(`SELECT nk.id, k.name, nk.schema_extension_id, nk.display_name, nk.description, @@ -450,11 +477,38 @@ func (s *BloodhoundDB) CreateGraphSchemaEdgeKind(ctx context.Context, name strin // populated with data, as well as an integer indicating the total number of rows returned by the query (excluding any given pagination). func (s *BloodhoundDB) GetGraphSchemaEdgeKinds(ctx context.Context, edgeKindFilters model.Filters, sort model.Sort, skip, limit int) (model.GraphSchemaEdgeKinds, int, error) { var ( - schemaEdgeKinds = model.GraphSchemaEdgeKinds{} - totalRowCount int + schemaEdgeKinds = model.GraphSchemaEdgeKinds{} + totalRowCount int + tableIdentifiedFilters = make(model.Filters, len(edgeKindFilters)) + tableIdentifiedSort = make(model.Sort, len(sort)) ) - if filterAndPagination, err := parseFiltersAndPagination(edgeKindFilters, sort, skip, limit); err != nil { + // add table identifiers to filtering and sorting columns ensuring we don't return an ambiguous column error + for column, filters := range edgeKindFilters { + if column == "name" { + tableIdentifiedFilters[fmt.Sprintf("%s.%s", "k", column)] = filters + delete(edgeKindFilters, column) + } else { + tableIdentifiedFilters[fmt.Sprintf("%s.%s", "ek", column)] = filters + delete(edgeKindFilters, column) + } + } + + for idx, sortItem := range sort { + if sort[idx].Column == "name" { + tableIdentifiedSort[idx] = model.SortItem{ + Direction: sortItem.Direction, + Column: fmt.Sprintf("%s.%s", "k", sort[idx].Column), + } + } else { + tableIdentifiedSort[idx] = model.SortItem{ + Direction: sortItem.Direction, + Column: fmt.Sprintf("%s.%s", "ek", sort[idx].Column), + } + } + } + + if filterAndPagination, err := parseFiltersAndPagination(tableIdentifiedFilters, tableIdentifiedSort, skip, limit); err != nil { return schemaEdgeKinds, 0, err } else { sqlStr := fmt.Sprintf(`SELECT ek.id, k.name, ek.schema_extension_id, ek.description, ek.is_traversable, diff --git a/cmd/api/src/database/graphschema_integration_test.go b/cmd/api/src/database/graphschema_integration_test.go index 28db314d6ae..bdded8908da 100644 --- a/cmd/api/src/database/graphschema_integration_test.go +++ b/cmd/api/src/database/graphschema_integration_test.go @@ -458,7 +458,7 @@ func TestDatabase_GraphSchemaNodeKind_CRUD(t *testing.T) { t.Run("fail - return error for filtering on non-existent column", func(t *testing.T) { _, _, err = testSuite.BHDatabase.GetGraphSchemaNodeKinds(testSuite.Context, model.Filters{"nonexistentcolumn": []model.Filter{{Operator: model.Equals, Value: "blah", SetOperator: model.FilterAnd}}}, model.Sort{}, 0, 0) - require.EqualError(t, err, "ERROR: column \"nonexistentcolumn\" does not exist (SQLSTATE 42703)") + require.EqualError(t, err, "ERROR: column nk.nonexistentcolumn does not exist (SQLSTATE 42703)") }) // UPDATE @@ -896,7 +896,7 @@ func TestDatabase_GraphSchemaEdgeKind_CRUD(t *testing.T) { t.Run("fail - return error for filtering on non-existent column", func(t *testing.T) { _, _, err = testSuite.BHDatabase.GetGraphSchemaEdgeKinds(testSuite.Context, model.Filters{"nonexistentcolumn": []model.Filter{{Operator: model.Equals, Value: "blah", SetOperator: model.FilterAnd}}}, model.Sort{}, 0, 0) - require.EqualError(t, err, "ERROR: column \"nonexistentcolumn\" does not exist (SQLSTATE 42703)") + require.EqualError(t, err, "ERROR: column ek.nonexistentcolumn does not exist (SQLSTATE 42703)") }) // UPDATE diff --git a/cmd/api/src/database/migration/extensions/ad_graph_schema.sql b/cmd/api/src/database/migration/extensions/ad_graph_schema.sql new file mode 100644 index 00000000000..b226693e513 --- /dev/null +++ b/cmd/api/src/database/migration/extensions/ad_graph_schema.sql @@ -0,0 +1,284 @@ +-- Copyright 2026 Specter Ops, Inc. +-- +-- Licensed under the Apache License, Version 2.0 +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- +-- SPDX-License-Identifier: Apache-2.0 +-- Code generated by Cuelang code gen. DO NOT EDIT! +-- Cuelang source: github.com/specterops/bloodhound/-/tree/main/packages/cue/schemas/ +CREATE OR REPLACE FUNCTION genscript_upsert_kind(node_kind_name TEXT) RETURNS void AS $$ +BEGIN + IF NOT EXISTS (SELECT id FROM kind WHERE kind.name = node_kind_name) THEN + INSERT INTO kind (name) VALUES (node_kind_name); + END IF; +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 + upserted_kind_id SMALLINT; +BEGIN + SELECT id INTO upserted_kind_id FROM kind WHERE name = v_kind_name; + IF upserted_kind_id IS NULL THEN + RAISE EXCEPTION 'no kind name'; + END IF; + + IF NOT EXISTS (SELECT id FROM schema_node_kinds nk WHERE nk.kind_id = upserted_kind_id) THEN + INSERT INTO schema_node_kinds (schema_extension_id, kind_id, display_name, description, is_display_kind, icon, icon_color) VALUES (v_extension_id, upserted_kind_id, v_display_name, v_description, v_is_display_kind, v_icon, v_icon_color); + ELSE + UPDATE schema_node_kinds SET display_name = v_display_name, description = v_description, is_display_kind = v_is_display_kind, icon = v_icon, icon_color = v_icon_color WHERE kind_id = upserted_kind_id; + END IF; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION genscript_upsert_schema_edge_kind(v_extension_id INT, v_kind_name VARCHAR(256), v_description TEXT, v_is_traversable BOOLEAN) RETURNS void AS $$ +DECLARE + upserted_kind_id SMALLINT; +BEGIN + SELECT id INTO upserted_kind_id FROM kind WHERE name = v_kind_name; + IF upserted_kind_id IS NULL THEN + RAISE EXCEPTION 'no kind name'; + END IF; + + IF NOT EXISTS (SELECT id FROM schema_edge_kinds ek WHERE ek.kind_id = upserted_kind_id) THEN + INSERT INTO schema_edge_kinds (schema_extension_id, kind_id, description, is_traversable) VALUES (v_extension_id, upserted_kind_id, v_description, v_is_traversable); + ELSE + UPDATE schema_edge_kinds SET description = v_description, is_traversable = v_is_traversable WHERE kind_id = upserted_kind_id; + END IF; +END; +$$ LANGUAGE plpgsql; + +DO $$ +DECLARE + extension_id INT; +BEGIN + LOCK schema_extensions, schema_node_kinds, schema_edge_kinds, kind; + + IF NOT EXISTS (SELECT id FROM schema_extensions WHERE name = 'AD') THEN + INSERT INTO schema_extensions (name, display_name, version, is_builtin) VALUES ('AD', 'Active Directory', 'v0.0.1', true) RETURNING id INTO extension_id; + ELSE + UPDATE schema_extensions SET display_name = 'Active Directory', version = 'v0.0.1' WHERE name = 'AD' RETURNING id INTO extension_id; + END IF; + + -- Insert Node Kinds + PERFORM genscript_upsert_kind('Base'); + PERFORM genscript_upsert_kind('User'); + PERFORM genscript_upsert_kind('Computer'); + PERFORM genscript_upsert_kind('Group'); + PERFORM genscript_upsert_kind('GPO'); + PERFORM genscript_upsert_kind('OU'); + PERFORM genscript_upsert_kind('Container'); + PERFORM genscript_upsert_kind('Domain'); + PERFORM genscript_upsert_kind('ADLocalGroup'); + PERFORM genscript_upsert_kind('ADLocalUser'); + PERFORM genscript_upsert_kind('AIACA'); + PERFORM genscript_upsert_kind('RootCA'); + PERFORM genscript_upsert_kind('EnterpriseCA'); + PERFORM genscript_upsert_kind('NTAuthStore'); + PERFORM genscript_upsert_kind('CertTemplate'); + PERFORM genscript_upsert_kind('IssuancePolicy'); + + -- Insert Relationship Kinds + PERFORM genscript_upsert_kind('Owns'); + PERFORM genscript_upsert_kind('GenericAll'); + PERFORM genscript_upsert_kind('GenericWrite'); + PERFORM genscript_upsert_kind('WriteOwner'); + PERFORM genscript_upsert_kind('WriteDacl'); + PERFORM genscript_upsert_kind('MemberOf'); + PERFORM genscript_upsert_kind('ForceChangePassword'); + PERFORM genscript_upsert_kind('AllExtendedRights'); + PERFORM genscript_upsert_kind('AddMember'); + PERFORM genscript_upsert_kind('HasSession'); + PERFORM genscript_upsert_kind('Contains'); + PERFORM genscript_upsert_kind('GPLink'); + PERFORM genscript_upsert_kind('AllowedToDelegate'); + PERFORM genscript_upsert_kind('CoerceToTGT'); + PERFORM genscript_upsert_kind('GetChanges'); + PERFORM genscript_upsert_kind('GetChangesAll'); + PERFORM genscript_upsert_kind('GetChangesInFilteredSet'); + PERFORM genscript_upsert_kind('CrossForestTrust'); + PERFORM genscript_upsert_kind('SameForestTrust'); + PERFORM genscript_upsert_kind('SpoofSIDHistory'); + PERFORM genscript_upsert_kind('AbuseTGTDelegation'); + PERFORM genscript_upsert_kind('AllowedToAct'); + PERFORM genscript_upsert_kind('AdminTo'); + PERFORM genscript_upsert_kind('CanPSRemote'); + PERFORM genscript_upsert_kind('CanRDP'); + PERFORM genscript_upsert_kind('ExecuteDCOM'); + PERFORM genscript_upsert_kind('HasSIDHistory'); + PERFORM genscript_upsert_kind('AddSelf'); + PERFORM genscript_upsert_kind('DCSync'); + PERFORM genscript_upsert_kind('ReadLAPSPassword'); + PERFORM genscript_upsert_kind('ReadGMSAPassword'); + PERFORM genscript_upsert_kind('DumpSMSAPassword'); + PERFORM genscript_upsert_kind('SQLAdmin'); + PERFORM genscript_upsert_kind('AddAllowedToAct'); + PERFORM genscript_upsert_kind('WriteSPN'); + PERFORM genscript_upsert_kind('AddKeyCredentialLink'); + PERFORM genscript_upsert_kind('LocalToComputer'); + PERFORM genscript_upsert_kind('MemberOfLocalGroup'); + PERFORM genscript_upsert_kind('RemoteInteractiveLogonRight'); + PERFORM genscript_upsert_kind('SyncLAPSPassword'); + PERFORM genscript_upsert_kind('WriteAccountRestrictions'); + PERFORM genscript_upsert_kind('WriteGPLink'); + PERFORM genscript_upsert_kind('RootCAFor'); + PERFORM genscript_upsert_kind('DCFor'); + PERFORM genscript_upsert_kind('PublishedTo'); + PERFORM genscript_upsert_kind('ManageCertificates'); + PERFORM genscript_upsert_kind('ManageCA'); + PERFORM genscript_upsert_kind('DelegatedEnrollmentAgent'); + PERFORM genscript_upsert_kind('Enroll'); + PERFORM genscript_upsert_kind('HostsCAService'); + PERFORM genscript_upsert_kind('WritePKIEnrollmentFlag'); + PERFORM genscript_upsert_kind('WritePKINameFlag'); + PERFORM genscript_upsert_kind('NTAuthStoreFor'); + PERFORM genscript_upsert_kind('TrustedForNTAuth'); + PERFORM genscript_upsert_kind('EnterpriseCAFor'); + PERFORM genscript_upsert_kind('IssuedSignedBy'); + PERFORM genscript_upsert_kind('GoldenCert'); + PERFORM genscript_upsert_kind('EnrollOnBehalfOf'); + PERFORM genscript_upsert_kind('OIDGroupLink'); + PERFORM genscript_upsert_kind('ExtendedByPolicy'); + PERFORM genscript_upsert_kind('ADCSESC1'); + PERFORM genscript_upsert_kind('ADCSESC3'); + PERFORM genscript_upsert_kind('ADCSESC4'); + PERFORM genscript_upsert_kind('ADCSESC6a'); + PERFORM genscript_upsert_kind('ADCSESC6b'); + PERFORM genscript_upsert_kind('ADCSESC9a'); + PERFORM genscript_upsert_kind('ADCSESC9b'); + PERFORM genscript_upsert_kind('ADCSESC10a'); + PERFORM genscript_upsert_kind('ADCSESC10b'); + PERFORM genscript_upsert_kind('ADCSESC13'); + PERFORM genscript_upsert_kind('SyncedToEntraUser'); + PERFORM genscript_upsert_kind('CoerceAndRelayNTLMToSMB'); + PERFORM genscript_upsert_kind('CoerceAndRelayNTLMToADCS'); + PERFORM genscript_upsert_kind('WriteOwnerLimitedRights'); + PERFORM genscript_upsert_kind('WriteOwnerRaw'); + PERFORM genscript_upsert_kind('OwnsLimitedRights'); + PERFORM genscript_upsert_kind('OwnsRaw'); + PERFORM genscript_upsert_kind('ClaimSpecialIdentity'); + PERFORM genscript_upsert_kind('CoerceAndRelayNTLMToLDAP'); + PERFORM genscript_upsert_kind('CoerceAndRelayNTLMToLDAPS'); + PERFORM genscript_upsert_kind('ContainsIdentity'); + PERFORM genscript_upsert_kind('PropagatesACEsTo'); + PERFORM genscript_upsert_kind('GPOAppliesTo'); + PERFORM genscript_upsert_kind('CanApplyGPO'); + PERFORM genscript_upsert_kind('HasTrustKeys'); + PERFORM genscript_upsert_kind('ProtectAdminGroups'); + + PERFORM genscript_upsert_schema_node_kind(extension_id, 'Base', 'Base', '', false, '', ''); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'User', 'User', '', true, 'fa-user', '#17E625'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'Computer', 'Computer', '', true, 'fa-desktop', '#E67873'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'Group', 'Group', '', true, 'fa-users', '#DBE617'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'GPO', 'GPO', '', true, 'fa-list', '#998EFD'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'OU', 'OU', '', true, 'fa-sitemap', '#FFAA00'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'Container', 'Container', '', true, 'fa-box', '#F79A78'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'Domain', 'Domain', '', true, 'fa-globe', '#17E6B9'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'ADLocalGroup', 'ADLocalGroup', '', false, '', ''); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'ADLocalUser', 'ADLocalUser', '', false, '', ''); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AIACA', 'AIACA', '', true, 'fa-arrows-left-right-to-line', '#9769F0'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'RootCA', 'RootCA', '', true, 'fa-landmark', '#6968E8'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'EnterpriseCA', 'EnterpriseCA', '', true, 'fa-building', '#4696E9'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'NTAuthStore', 'NTAuthStore', '', true, 'fa-store', '#D575F5'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'CertTemplate', 'CertTemplate', '', true, 'fa-id-card', '#B153F3'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'IssuancePolicy', 'IssuancePolicy', '', true, 'fa-clipboard-check', '#99B2DD'); + + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'Owns', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'GenericAll', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'GenericWrite', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'WriteOwner', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'WriteDacl', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'MemberOf', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'ForceChangePassword', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AllExtendedRights', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AddMember', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'HasSession', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'Contains', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'GPLink', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AllowedToDelegate', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'CoerceToTGT', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'GetChanges', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'GetChangesAll', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'GetChangesInFilteredSet', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'CrossForestTrust', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'SameForestTrust', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'SpoofSIDHistory', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AbuseTGTDelegation', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AllowedToAct', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AdminTo', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'CanPSRemote', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'CanRDP', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'ExecuteDCOM', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'HasSIDHistory', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AddSelf', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'DCSync', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'ReadLAPSPassword', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'ReadGMSAPassword', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'DumpSMSAPassword', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'SQLAdmin', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AddAllowedToAct', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'WriteSPN', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AddKeyCredentialLink', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'LocalToComputer', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'MemberOfLocalGroup', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'RemoteInteractiveLogonRight', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'SyncLAPSPassword', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'WriteAccountRestrictions', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'WriteGPLink', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'RootCAFor', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'DCFor', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'PublishedTo', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'ManageCertificates', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'ManageCA', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'DelegatedEnrollmentAgent', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'Enroll', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'HostsCAService', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'WritePKIEnrollmentFlag', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'WritePKINameFlag', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'NTAuthStoreFor', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'TrustedForNTAuth', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'EnterpriseCAFor', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'IssuedSignedBy', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'GoldenCert', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'EnrollOnBehalfOf', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'OIDGroupLink', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'ExtendedByPolicy', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'ADCSESC1', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'ADCSESC3', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'ADCSESC4', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'ADCSESC6a', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'ADCSESC6b', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'ADCSESC9a', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'ADCSESC9b', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'ADCSESC10a', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'ADCSESC10b', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'ADCSESC13', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'SyncedToEntraUser', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'CoerceAndRelayNTLMToSMB', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'CoerceAndRelayNTLMToADCS', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'WriteOwnerLimitedRights', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'WriteOwnerRaw', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'OwnsLimitedRights', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'OwnsRaw', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'ClaimSpecialIdentity', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'CoerceAndRelayNTLMToLDAP', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'CoerceAndRelayNTLMToLDAPS', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'ContainsIdentity', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'PropagatesACEsTo', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'GPOAppliesTo', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'CanApplyGPO', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'HasTrustKeys', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'ProtectAdminGroups', '', false); +END $$; + +DROP FUNCTION IF EXISTS genscript_upsert_kind; +DROP FUNCTION IF EXISTS genscript_upsert_schema_node_kind; +DROP FUNCTION IF EXISTS genscript_upsert_schema_edge_kind; diff --git a/cmd/api/src/database/migration/extensions/az_graph_schema.sql b/cmd/api/src/database/migration/extensions/az_graph_schema.sql new file mode 100644 index 00000000000..24006eb7d94 --- /dev/null +++ b/cmd/api/src/database/migration/extensions/az_graph_schema.sql @@ -0,0 +1,218 @@ +-- Copyright 2026 Specter Ops, Inc. +-- +-- Licensed under the Apache License, Version 2.0 +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- +-- SPDX-License-Identifier: Apache-2.0 +-- Code generated by Cuelang code gen. DO NOT EDIT! +-- Cuelang source: github.com/specterops/bloodhound/-/tree/main/packages/cue/schemas/ +CREATE OR REPLACE FUNCTION genscript_upsert_kind(node_kind_name TEXT) RETURNS void AS $$ +BEGIN + IF NOT EXISTS (SELECT id FROM kind WHERE kind.name = node_kind_name) THEN + INSERT INTO kind (name) VALUES (node_kind_name); + END IF; +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 + upserted_kind_id SMALLINT; +BEGIN + SELECT id INTO upserted_kind_id FROM kind WHERE name = v_kind_name; + IF upserted_kind_id IS NULL THEN + RAISE EXCEPTION 'no kind name'; + END IF; + + IF NOT EXISTS (SELECT id FROM schema_node_kinds nk WHERE nk.kind_id = upserted_kind_id) THEN + INSERT INTO schema_node_kinds (schema_extension_id, kind_id, display_name, description, is_display_kind, icon, icon_color) VALUES (v_extension_id, upserted_kind_id, v_display_name, v_description, v_is_display_kind, v_icon, v_icon_color); + ELSE + UPDATE schema_node_kinds SET display_name = v_display_name, description = v_description, is_display_kind = v_is_display_kind, icon = v_icon, icon_color = v_icon_color WHERE kind_id = upserted_kind_id; + END IF; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION genscript_upsert_schema_edge_kind(v_extension_id INT, v_kind_name VARCHAR(256), v_description TEXT, v_is_traversable BOOLEAN) RETURNS void AS $$ +DECLARE + upserted_kind_id SMALLINT; +BEGIN + SELECT id INTO upserted_kind_id FROM kind WHERE name = v_kind_name; + IF upserted_kind_id IS NULL THEN + RAISE EXCEPTION 'no kind name'; + END IF; + + IF NOT EXISTS (SELECT id FROM schema_edge_kinds ek WHERE ek.kind_id = upserted_kind_id) THEN + INSERT INTO schema_edge_kinds (schema_extension_id, kind_id, description, is_traversable) VALUES (v_extension_id, upserted_kind_id, v_description, v_is_traversable); + ELSE + UPDATE schema_edge_kinds SET description = v_description, is_traversable = v_is_traversable WHERE kind_id = upserted_kind_id; + END IF; +END; +$$ LANGUAGE plpgsql; + +DO $$ +DECLARE + extension_id INT; +BEGIN + LOCK schema_extensions, schema_node_kinds, schema_edge_kinds, kind; + + IF NOT EXISTS (SELECT id FROM schema_extensions WHERE name = 'AZ') THEN + INSERT INTO schema_extensions (name, display_name, version, is_builtin) VALUES ('AZ', 'Azure', 'v0.0.1', true) RETURNING id INTO extension_id; + ELSE + UPDATE schema_extensions SET display_name = 'Azure', version = 'v0.0.1' WHERE name = 'AZ' RETURNING id INTO extension_id; + END IF; + + -- Insert Node Kinds + PERFORM genscript_upsert_kind('AZBase'); + PERFORM genscript_upsert_kind('AZVMScaleSet'); + PERFORM genscript_upsert_kind('AZApp'); + PERFORM genscript_upsert_kind('AZRole'); + PERFORM genscript_upsert_kind('AZDevice'); + PERFORM genscript_upsert_kind('AZFunctionApp'); + PERFORM genscript_upsert_kind('AZGroup'); + PERFORM genscript_upsert_kind('AZKeyVault'); + PERFORM genscript_upsert_kind('AZManagementGroup'); + PERFORM genscript_upsert_kind('AZResourceGroup'); + PERFORM genscript_upsert_kind('AZServicePrincipal'); + PERFORM genscript_upsert_kind('AZSubscription'); + PERFORM genscript_upsert_kind('AZTenant'); + PERFORM genscript_upsert_kind('AZUser'); + PERFORM genscript_upsert_kind('AZVM'); + PERFORM genscript_upsert_kind('AZManagedCluster'); + PERFORM genscript_upsert_kind('AZContainerRegistry'); + PERFORM genscript_upsert_kind('AZWebApp'); + PERFORM genscript_upsert_kind('AZLogicApp'); + PERFORM genscript_upsert_kind('AZAutomationAccount'); + + -- Insert Relationship Kinds + PERFORM genscript_upsert_kind('AZAvereContributor'); + PERFORM genscript_upsert_kind('AZContains'); + PERFORM genscript_upsert_kind('AZContributor'); + PERFORM genscript_upsert_kind('AZGetCertificates'); + PERFORM genscript_upsert_kind('AZGetKeys'); + PERFORM genscript_upsert_kind('AZGetSecrets'); + PERFORM genscript_upsert_kind('AZHasRole'); + PERFORM genscript_upsert_kind('AZMemberOf'); + PERFORM genscript_upsert_kind('AZOwner'); + PERFORM genscript_upsert_kind('AZRunsAs'); + PERFORM genscript_upsert_kind('AZVMContributor'); + PERFORM genscript_upsert_kind('AZAutomationContributor'); + PERFORM genscript_upsert_kind('AZKeyVaultContributor'); + PERFORM genscript_upsert_kind('AZVMAdminLogin'); + PERFORM genscript_upsert_kind('AZAddMembers'); + PERFORM genscript_upsert_kind('AZAddSecret'); + PERFORM genscript_upsert_kind('AZExecuteCommand'); + PERFORM genscript_upsert_kind('AZGlobalAdmin'); + PERFORM genscript_upsert_kind('AZPrivilegedAuthAdmin'); + PERFORM genscript_upsert_kind('AZGrant'); + PERFORM genscript_upsert_kind('AZGrantSelf'); + PERFORM genscript_upsert_kind('AZPrivilegedRoleAdmin'); + PERFORM genscript_upsert_kind('AZResetPassword'); + PERFORM genscript_upsert_kind('AZUserAccessAdministrator'); + PERFORM genscript_upsert_kind('AZOwns'); + PERFORM genscript_upsert_kind('AZScopedTo'); + PERFORM genscript_upsert_kind('AZCloudAppAdmin'); + PERFORM genscript_upsert_kind('AZAppAdmin'); + PERFORM genscript_upsert_kind('AZAddOwner'); + PERFORM genscript_upsert_kind('AZManagedIdentity'); + PERFORM genscript_upsert_kind('AZMGApplication_ReadWrite_All'); + PERFORM genscript_upsert_kind('AZMGAppRoleAssignment_ReadWrite_All'); + PERFORM genscript_upsert_kind('AZMGDirectory_ReadWrite_All'); + PERFORM genscript_upsert_kind('AZMGGroup_ReadWrite_All'); + PERFORM genscript_upsert_kind('AZMGGroupMember_ReadWrite_All'); + PERFORM genscript_upsert_kind('AZMGRoleManagement_ReadWrite_Directory'); + PERFORM genscript_upsert_kind('AZMGServicePrincipalEndpoint_ReadWrite_All'); + PERFORM genscript_upsert_kind('AZAKSContributor'); + PERFORM genscript_upsert_kind('AZNodeResourceGroup'); + PERFORM genscript_upsert_kind('AZWebsiteContributor'); + PERFORM genscript_upsert_kind('AZLogicAppContributor'); + PERFORM genscript_upsert_kind('AZMGAddMember'); + PERFORM genscript_upsert_kind('AZMGAddOwner'); + PERFORM genscript_upsert_kind('AZMGAddSecret'); + PERFORM genscript_upsert_kind('AZMGGrantAppRoles'); + PERFORM genscript_upsert_kind('AZMGGrantRole'); + PERFORM genscript_upsert_kind('SyncedToADUser'); + PERFORM genscript_upsert_kind('AZRoleEligible'); + PERFORM genscript_upsert_kind('AZRoleApprover'); + + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZBase', 'AZBase', '', false, '', ''); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZVMScaleSet', 'AZVMScaleSet', '', true, 'fa-server', '#007CD0'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZApp', 'AZApp', '', true, 'fa-window-restore', '#03FC84'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZRole', 'AZRole', '', true, 'fa-clipboard-list', '#ED8537'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZDevice', 'AZDevice', '', true, 'fa-desktop', '#B18FCF'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZFunctionApp', 'AZFunctionApp', '', true, 'fa-bolt', '#F4BA44'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZGroup', 'AZGroup', '', true, 'fa-users', '#F57C9B'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZKeyVault', 'AZKeyVault', '', true, 'fa-lock', '#ED658C'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZManagementGroup', 'AZManagementGroup', '', true, 'fa-sitemap', '#BD93D8'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZResourceGroup', 'AZResourceGroup', '', true, 'fa-cube', '#89BD9E'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZServicePrincipal', 'AZServicePrincipal', '', true, 'fa-robot', '#C1D6D6'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZSubscription', 'AZSubscription', '', true, 'fa-key', '#D2CCA1'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZTenant', 'AZTenant', '', true, 'fa-cloud', '#54F2F2'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZUser', 'AZUser', '', true, 'fa-user', '#34D2EB'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZVM', 'AZVM', '', true, 'fa-desktop', '#F9ADA0'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZManagedCluster', 'AZManagedCluster', '', true, 'fa-cubes', '#326CE5'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZContainerRegistry', 'AZContainerRegistry', '', true, 'fa-box-open', '#0885D7'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZWebApp', 'AZWebApp', '', true, 'fa-object-group', '#4696E9'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZLogicApp', 'AZLogicApp', '', true, 'fa-sitemap', '#9EE047'); + PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZAutomationAccount', 'AZAutomationAccount', '', true, 'fa-cog', '#F4BA44'); + + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZAvereContributor', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZContains', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZContributor', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZGetCertificates', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZGetKeys', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZGetSecrets', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZHasRole', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZMemberOf', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZOwner', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZRunsAs', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZVMContributor', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZAutomationContributor', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZKeyVaultContributor', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZVMAdminLogin', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZAddMembers', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZAddSecret', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZExecuteCommand', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZGlobalAdmin', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZPrivilegedAuthAdmin', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZGrant', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZGrantSelf', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZPrivilegedRoleAdmin', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZResetPassword', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZUserAccessAdministrator', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZOwns', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZScopedTo', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZCloudAppAdmin', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZAppAdmin', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZAddOwner', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZManagedIdentity', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZMGApplication_ReadWrite_All', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZMGAppRoleAssignment_ReadWrite_All', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZMGDirectory_ReadWrite_All', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZMGGroup_ReadWrite_All', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZMGGroupMember_ReadWrite_All', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZMGRoleManagement_ReadWrite_Directory', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZMGServicePrincipalEndpoint_ReadWrite_All', '', false); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZAKSContributor', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZNodeResourceGroup', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZWebsiteContributor', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZLogicAppContributor', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZMGAddMember', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZMGAddOwner', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZMGAddSecret', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZMGGrantAppRoles', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZMGGrantRole', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'SyncedToADUser', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZRoleEligible', '', true); + PERFORM genscript_upsert_schema_edge_kind(extension_id, 'AZRoleApprover', '', true); +END $$; + +DROP FUNCTION IF EXISTS genscript_upsert_kind; +DROP FUNCTION IF EXISTS genscript_upsert_schema_node_kind; +DROP FUNCTION IF EXISTS genscript_upsert_schema_edge_kind; diff --git a/cmd/api/src/database/migration/migration.go b/cmd/api/src/database/migration/migration.go index e11fcbdd828..fc29588a8fd 100644 --- a/cmd/api/src/database/migration/migration.go +++ b/cmd/api/src/database/migration/migration.go @@ -27,6 +27,9 @@ import ( //go:embed migrations var FossMigrations embed.FS +//go:embed extensions +var ExtensionMigrations embed.FS + // Source is meant to be a file system source that contains SQL migration files. type Source struct { FileSystem fs.FS @@ -42,8 +45,9 @@ type Migration struct { // Migrator is the main SQL migration tool for BloodHound. type Migrator struct { - Sources []Source - DB *gorm.DB + Sources []Source + ExtensionsData []Source + DB *gorm.DB } // NewMigrator returns a new Migrator with the FossMigrations Source predefined. @@ -52,6 +56,9 @@ func NewMigrator(db *gorm.DB) *Migrator { Sources: []Source{ {FileSystem: FossMigrations, Directory: "migrations"}, }, + ExtensionsData: []Source{ + {FileSystem: ExtensionMigrations, Directory: "extensions"}, + }, DB: db, } } diff --git a/cmd/api/src/database/migration/stepwise.go b/cmd/api/src/database/migration/stepwise.go index 9e17b6c123d..78ba63097ff 100644 --- a/cmd/api/src/database/migration/stepwise.go +++ b/cmd/api/src/database/migration/stepwise.go @@ -20,6 +20,8 @@ import ( "fmt" "io/fs" "log/slog" + "path/filepath" + "strings" "github.com/specterops/bloodhound/cmd/api/src/model" "github.com/specterops/bloodhound/cmd/api/src/version" @@ -49,7 +51,7 @@ func (s *Migrator) ExecuteMigrations(manifest Manifest) error { } // execute the migration(s) for this version in a transaction - slog.Info(fmt.Sprintf("Executing SQL migrations for %s", versionString)) + slog.Info("Executing SQL migrations", slog.String("version", versionString)) if err := s.DB.Transaction(func(tx *gorm.DB) error { for _, migration := range manifest.Migrations[versionString] { @@ -193,3 +195,45 @@ func (s *Migrator) ExecuteStepwiseMigrations() error { return nil } } + +func (s *Migrator) ExecuteExtensionDataPopulation() error { + const migrationSQLFilenameSuffix = ".sql" + + // loop through extensions data + for _, source := range s.ExtensionsData { + dirEntries, err := fs.ReadDir(source.FileSystem, source.Directory) + if err != nil { + return err + } + + // loop through file system entries + for _, entry := range dirEntries { + if entry.IsDir() { + continue + } + + filename := filepath.Join(source.Directory, entry.Name()) + basename := filepath.Base(filename) + + if !strings.HasSuffix(basename, migrationSQLFilenameSuffix) { + continue + } + + slog.Info("Executing extension data population", slog.String("file", basename)) + if err := s.DB.Transaction(func(tx *gorm.DB) error { + // read migration file content and execute + if migrationContent, err := fs.ReadFile(source.FileSystem, filename); err != nil { + return err + } else if result := tx.Exec(string(migrationContent)); result.Error != nil { + return result.Error + } + + return nil + }); err != nil { + return fmt.Errorf("failed to execute extension data population for %s: %w", basename, err) + } + } + } + + return nil +} diff --git a/cmd/api/src/database/mocks/db.go b/cmd/api/src/database/mocks/db.go index cf16fece304..f3c29cb4c49 100644 --- a/cmd/api/src/database/mocks/db.go +++ b/cmd/api/src/database/mocks/db.go @@ -2587,6 +2587,20 @@ func (mr *MockDatabaseMockRecorder) Migrate(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Migrate", reflect.TypeOf((*MockDatabase)(nil).Migrate), ctx) } +// PopulateExtensionData mocks base method. +func (m *MockDatabase) PopulateExtensionData(ctx context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PopulateExtensionData", ctx) + ret0, _ := ret[0].(error) + return ret0 +} + +// PopulateExtensionData indicates an expected call of PopulateExtensionData. +func (mr *MockDatabaseMockRecorder) PopulateExtensionData(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PopulateExtensionData", reflect.TypeOf((*MockDatabase)(nil).PopulateExtensionData), ctx) +} + // RegisterSourceKind mocks base method. func (m *MockDatabase) RegisterSourceKind(ctx context.Context) func(graph.Kind) error { m.ctrl.T.Helper() diff --git a/cmd/api/src/services/entrypoint.go b/cmd/api/src/services/entrypoint.go index 4ab5a38745b..7b946f73f62 100644 --- a/cmd/api/src/services/entrypoint.go +++ b/cmd/api/src/services/entrypoint.go @@ -91,6 +91,8 @@ func Entrypoint(ctx context.Context, cfg config.Configuration, connections boots return nil, fmt.Errorf("rdms migration error: %w", err) } else if err := migrations.NewGraphMigrator(connections.Graph).Migrate(ctx); err != nil { return nil, fmt.Errorf("graph migration error: %w", err) + } else if err := bootstrap.PopulateExtensionData(ctx, connections.RDMS); err != nil { + return nil, fmt.Errorf("extensions data population error: %w", err) } } else if err := connections.Graph.SetDefaultGraph(ctx, schema.DefaultGraph()); err != nil { return nil, fmt.Errorf("no default graph found but migrations are disabled per configuration: %w", err) diff --git a/cmd/api/src/services/graphify/graphify_integration_test.go b/cmd/api/src/services/graphify/graphify_integration_test.go index 8dfd4ddd945..76dc9c9a7cf 100644 --- a/cmd/api/src/services/graphify/graphify_integration_test.go +++ b/cmd/api/src/services/graphify/graphify_integration_test.go @@ -88,6 +88,9 @@ func setupIntegrationTestSuite(t *testing.T, fixturesPath string) IntegrationTes err = graphDB.AssertSchema(ctx, graphschema.DefaultGraphSchema()) require.NoError(t, err) + err = db.PopulateExtensionData(ctx) + require.NoError(t, err) + ingestSchema, err := upload.LoadIngestSchema() require.NoError(t, err) diff --git a/cmd/api/src/test/integration/database.go b/cmd/api/src/test/integration/database.go index 53ef0b7ae79..b76417f56fd 100644 --- a/cmd/api/src/test/integration/database.go +++ b/cmd/api/src/test/integration/database.go @@ -136,6 +136,8 @@ func Prepare(ctx context.Context, db database.Database) error { return fmt.Errorf("failed to clear database: %v", err) } else if err := db.Migrate(ctx); err != nil { return fmt.Errorf("failed to migrate database: %v", err) + } else if err := db.PopulateExtensionData(ctx); err != nil { + return fmt.Errorf("failed to populate extension data: %v", err) } return nil diff --git a/cmd/api/src/test/lab/fixtures/postgres.go b/cmd/api/src/test/lab/fixtures/postgres.go index f63ad1851c1..a7b0bc91277 100644 --- a/cmd/api/src/test/lab/fixtures/postgres.go +++ b/cmd/api/src/test/lab/fixtures/postgres.go @@ -41,6 +41,8 @@ var PostgresFixture = lab.NewFixture(func(harness *lab.Harness) (*database.Blood return nil, fmt.Errorf("failed ensuring database: %v", err) } else if err := bootstrap.MigrateDB(testCtx, labConfig, database.NewBloodhoundDB(pgdb, auth.NewIdentityResolver()), config.NewDefaultAdminConfiguration); err != nil { return nil, fmt.Errorf("failed migrating database: %v", err) + } else if err := bootstrap.PopulateExtensionData(testCtx, database.NewBloodhoundDB(pgdb, auth.NewIdentityResolver())); err != nil { + return nil, fmt.Errorf("failed populating extension data: %v", err) } else { return database.NewBloodhoundDB(pgdb, auth.NewIdentityResolver()), nil } diff --git a/packages/go/graphify/graph/graph.go b/packages/go/graphify/graph/graph.go index db721083f5c..bd734c9aa2a 100644 --- a/packages/go/graphify/graph/graph.go +++ b/packages/go/graphify/graph/graph.go @@ -177,6 +177,8 @@ func (s *CommunityGraphService) InitializeService(ctx context.Context, connectio return fmt.Errorf("error migrating database: %w", err) } else if err := migrations.NewGraphMigrator(graphDB).Migrate(ctx); err != nil { return fmt.Errorf("error migrating graph schema: %w", err) + } else if err := s.db.PopulateExtensionData(ctx); err != nil { + return fmt.Errorf("error populating extension data: %w", err) } else if err = graphDB.SetDefaultGraph(ctx, graphschema.DefaultGraph()); err != nil { return fmt.Errorf("error setting default graph: %w", err) } diff --git a/packages/go/schemagen/generator/cue.go b/packages/go/schemagen/generator/cue.go index 4c970f353ae..c487a807178 100644 --- a/packages/go/schemagen/generator/cue.go +++ b/packages/go/schemagen/generator/cue.go @@ -95,7 +95,10 @@ func (s *ConfigBuilder) OverlayPath(rootPath string) error { } else { overlayPath := filepath.Join(s.overlayRootPath, strings.TrimPrefix(path, rootPath)) - slog.Debug(fmt.Sprintf("Overlaying file: %s to %s", path, overlayPath)) + slog.Debug("Overlaying file", + slog.String("path", path), + slog.String("overlay_path", overlayPath), + ) s.overlay[overlayPath] = load.FromBytes(content) } diff --git a/packages/go/schemagen/generator/sql.go b/packages/go/schemagen/generator/sql.go new file mode 100644 index 00000000000..d7307774253 --- /dev/null +++ b/packages/go/schemagen/generator/sql.go @@ -0,0 +1,289 @@ +// Copyright 2025 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +package generator + +import ( + "fmt" + "os" + "path" + "strings" + + "github.com/specterops/bloodhound/packages/go/schemagen/model" +) + +type nodeIcon struct { + Icon string + Color string +} + +var nodeIcons = map[string]nodeIcon{ + // Active Directory Node Types + "User": { + Icon: "fa-user", + Color: "#17E625", + }, + "Group": { + Icon: "fa-users", + Color: "#DBE617", + }, + "Computer": { + Icon: "fa-desktop", + Color: "#E67873", + }, + "Domain": { + Icon: "fa-globe", + Color: "#17E6B9", + }, + "GPO": { + Icon: "fa-list", + Color: "#998EFD", + }, + "AIACA": { + Icon: "fa-arrows-left-right-to-line", + Color: "#9769F0", + }, + "RootCA": { + Icon: "fa-landmark", + Color: "#6968E8", + }, + "EnterpriseCA": { + Icon: "fa-building", + Color: "#4696E9", + }, + "NTAuthStore": { + Icon: "fa-store", + Color: "#D575F5", + }, + "CertTemplate": { + Icon: "fa-id-card", + Color: "#B153F3", + }, + "IssuancePolicy": { + Icon: "fa-clipboard-check", + Color: "#99B2DD", + }, + "OU": { + Icon: "fa-sitemap", + Color: "#FFAA00", + }, + "Container": { + Icon: "fa-box", + Color: "#F79A78", + }, + // Azure Node Types + "AZUser": { + Icon: "fa-user", + Color: "#34D2EB", + }, + "AZGroup": { + Icon: "fa-users", + Color: "#F57C9B", + }, + "AZTenant": { + Icon: "fa-cloud", + Color: "#54F2F2", + }, + "AZSubscription": { + Icon: "fa-key", + Color: "#D2CCA1", + }, + "AZResourceGroup": { + Icon: "fa-cube", + Color: "#89BD9E", + }, + "AZVM": { + Icon: "fa-desktop", + Color: "#F9ADA0", + }, + "AZWebApp": { + Icon: "fa-object-group", + Color: "#4696E9", + }, + "AZLogicApp": { + Icon: "fa-sitemap", + Color: "#9EE047", + }, + "AZAutomationAccount": { + Icon: "fa-cog", + Color: "#F4BA44", + }, + "AZFunctionApp": { + Icon: "fa-bolt", + Color: "#F4BA44", + }, + "AZContainerRegistry": { + Icon: "fa-box-open", + Color: "#0885D7", + }, + "AZManagedCluster": { + Icon: "fa-cubes", + Color: "#326CE5", + }, + "AZDevice": { + Icon: "fa-desktop", + Color: "#B18FCF", + }, + "AZKeyVault": { + Icon: "fa-lock", + Color: "#ED658C", + }, + "AZApp": { + Icon: "fa-window-restore", + Color: "#03FC84", + }, + "AZVMScaleSet": { + Icon: "fa-server", + Color: "#007CD0", + }, + "AZServicePrincipal": { + Icon: "fa-robot", + Color: "#C1D6D6", + }, + "AZRole": { + Icon: "fa-clipboard-list", + Color: "#ED8537", + }, + "AZManagementGroup": { + Icon: "fa-sitemap", + Color: "#BD93D8", + }, +} + +func GenerateExtensionSQLActiveDirectory(dir string, adSchema model.ActiveDirectory) error { + return GenerateExtensionSQL("AD", "Active Directory", "v0.0.1", dir, "ad_graph_schema.sql", adSchema.NodeKinds, adSchema.RelationshipKinds, adSchema.PathfindingRelationships) +} + +func GenerateExtensionSQLAzure(dir string, azSchema model.Azure) error { + return GenerateExtensionSQL("AZ", "Azure", "v0.0.1", dir, "az_graph_schema.sql", azSchema.NodeKinds, azSchema.RelationshipKinds, azSchema.PathfindingRelationships) +} + +func GenerateExtensionSQL(name string, displayName string, version string, dir string, fileName string, nodeKinds []model.StringEnum, relationshipKinds []model.StringEnum, pathfindingRelationshipKinds []model.StringEnum) error { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("-- Code generated by Cuelang code gen. DO NOT EDIT!\n-- Cuelang source: %s/", SchemaSourceName)) + + sb.WriteString(` +CREATE OR REPLACE FUNCTION genscript_upsert_kind(node_kind_name TEXT) RETURNS void AS $$ +BEGIN + IF NOT EXISTS (SELECT id FROM kind WHERE kind.name = node_kind_name) THEN + INSERT INTO kind (name) VALUES (node_kind_name); + END IF; +END $$ LANGUAGE plpgsql; + `) + + sb.WriteString(` +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 + upserted_kind_id SMALLINT; +BEGIN + SELECT id INTO upserted_kind_id FROM kind WHERE name = v_kind_name; + IF upserted_kind_id IS NULL THEN + RAISE EXCEPTION 'no kind name'; + END IF; + + IF NOT EXISTS (SELECT id FROM schema_node_kinds nk WHERE nk.kind_id = upserted_kind_id) THEN + INSERT INTO schema_node_kinds (schema_extension_id, kind_id, display_name, description, is_display_kind, icon, icon_color) VALUES (v_extension_id, upserted_kind_id, v_display_name, v_description, v_is_display_kind, v_icon, v_icon_color); + ELSE + UPDATE schema_node_kinds SET display_name = v_display_name, description = v_description, is_display_kind = v_is_display_kind, icon = v_icon, icon_color = v_icon_color WHERE kind_id = upserted_kind_id; + END IF; +END; +$$ LANGUAGE plpgsql; +`) + + sb.WriteString(` +CREATE OR REPLACE FUNCTION genscript_upsert_schema_edge_kind(v_extension_id INT, v_kind_name VARCHAR(256), v_description TEXT, v_is_traversable BOOLEAN) RETURNS void AS $$ +DECLARE + upserted_kind_id SMALLINT; +BEGIN + SELECT id INTO upserted_kind_id FROM kind WHERE name = v_kind_name; + IF upserted_kind_id IS NULL THEN + RAISE EXCEPTION 'no kind name'; + END IF; + + IF NOT EXISTS (SELECT id FROM schema_edge_kinds ek WHERE ek.kind_id = upserted_kind_id) THEN + INSERT INTO schema_edge_kinds (schema_extension_id, kind_id, description, is_traversable) VALUES (v_extension_id, upserted_kind_id, v_description, v_is_traversable); + ELSE + UPDATE schema_edge_kinds SET description = v_description, is_traversable = v_is_traversable WHERE kind_id = upserted_kind_id; + END IF; +END; +$$ LANGUAGE plpgsql; +`) + + sb.WriteString("\nDO $$\nDECLARE\n\textension_id INT;\nBEGIN\n\tLOCK schema_extensions, schema_node_kinds, schema_edge_kinds, kind;\n\n") + + sb.WriteString(fmt.Sprintf("\tIF NOT EXISTS (SELECT id FROM schema_extensions WHERE name = '%s') THEN\n", name)) + sb.WriteString(fmt.Sprintf("\t\tINSERT INTO schema_extensions (name, display_name, version, is_builtin) VALUES ('%s', '%s', '%s', true) RETURNING id INTO extension_id;\n", name, displayName, version)) + sb.WriteString("\tELSE\n") + sb.WriteString(fmt.Sprintf("\t\tUPDATE schema_extensions SET display_name = '%s', version = '%s' WHERE name = '%s' RETURNING id INTO extension_id;\n", displayName, version, name)) + sb.WriteString("\tEND IF;\n\n") + + sb.WriteString("\t-- Insert Node Kinds\n") + for _, kind := range nodeKinds { + sb.WriteString(fmt.Sprintf("\tPERFORM genscript_upsert_kind('%s');\n", kind.GetRepresentation())) + } + + sb.WriteString("\n\t-- Insert Relationship Kinds\n") + for _, kind := range relationshipKinds { + sb.WriteString(fmt.Sprintf("\tPERFORM genscript_upsert_kind('%s');\n", kind.GetRepresentation())) + } + + sb.WriteString("\n") + + for _, kind := range nodeKinds { + iconInfo, found := nodeIcons[kind.GetRepresentation()] + + sb.WriteString(fmt.Sprintf("\tPERFORM genscript_upsert_schema_node_kind(extension_id, '%s', '%s', '', %t, '%s', '%s');\n", kind.GetRepresentation(), kind.GetRepresentation(), found, iconInfo.Icon, iconInfo.Color)) + } + + traversableMap := make(map[string]struct{}) + + for _, kind := range pathfindingRelationshipKinds { + traversableMap[kind.GetRepresentation()] = struct{}{} + } + + sb.WriteString("\n") + + for _, kind := range relationshipKinds { + _, traversable := traversableMap[kind.GetRepresentation()] + + sb.WriteString(fmt.Sprintf("\tPERFORM genscript_upsert_schema_edge_kind(extension_id, '%s', '', %t);\n", kind.GetRepresentation(), traversable)) + } + + sb.WriteString("END $$;\n") + sb.WriteString(` +DROP FUNCTION IF EXISTS genscript_upsert_kind; +DROP FUNCTION IF EXISTS genscript_upsert_schema_node_kind; +DROP FUNCTION IF EXISTS genscript_upsert_schema_edge_kind;`) + + if _, err := os.Stat(dir); err != nil { + if !os.IsNotExist(err) { + return err + } + + if err := os.MkdirAll(dir, defaultPackageDirPermission); err != nil { + return err + } + } + + if fout, err := os.OpenFile(path.Join(dir, fileName), fileOpenMode, defaultSourceFilePermission); err != nil { + return err + } else { + defer fout.Close() + + _, err := fout.WriteString(sb.String()) + return err + } +} diff --git a/packages/go/schemagen/main.go b/packages/go/schemagen/main.go index 68bdfd1e432..8628e1fc4f0 100644 --- a/packages/go/schemagen/main.go +++ b/packages/go/schemagen/main.go @@ -17,12 +17,12 @@ package main import ( - "fmt" "log/slog" "os" "path/filepath" "cuelang.org/go/cue/errors" + "github.com/specterops/bloodhound/packages/go/bhlog/attr" "github.com/specterops/bloodhound/packages/go/schemagen/generator" "github.com/specterops/bloodhound/packages/go/schemagen/model" "github.com/specterops/bloodhound/packages/go/schemagen/tsgen" @@ -72,45 +72,62 @@ func GenerateCSharp(projectRoot string, rootSchema Schema) error { return generator.GenerateCSharpBindings(projectRoot, rootSchema.Common, rootSchema.ActiveDirectory) } +func GenerateSQL(projectRoot string, rootSchema Schema) error { + if err := generator.GenerateExtensionSQLActiveDirectory(filepath.Join(projectRoot, "cmd/api/src/database/migration/extensions"), rootSchema.ActiveDirectory); err != nil { + return err + } + + if err := generator.GenerateExtensionSQLAzure(filepath.Join(projectRoot, "cmd/api/src/database/migration/extensions"), rootSchema.Azure); err != nil { + return err + } + + return nil +} + func main() { cfgBuilder := generator.NewConfigBuilder("/schemas") if projectRoot, err := generator.FindGolangWorkspaceRoot(); err != nil { - slog.Error(fmt.Sprintf("Error finding project root: %v", err)) + slog.Error("Error finding project root", attr.Error(err)) os.Exit(1) } else { - slog.Info(fmt.Sprintf("Project root is %s", projectRoot)) + slog.Info("Found project root", slog.String("project_root", projectRoot)) if err := cfgBuilder.OverlayPath(filepath.Join(projectRoot, "packages/cue")); err != nil { - slog.Error(fmt.Sprintf("Error: %v", err)) + slog.Error("Failed to read overlay path", attr.Error(err)) os.Exit(1) } cfg := cfgBuilder.Build() if bhInstance, err := cfg.Value("/schemas/bh/bh.cue"); err != nil { - slog.Error(fmt.Sprintf("Error: %v", errors.Details(err, nil))) + slog.Error("Failed to load cue schema", slog.String("err", errors.Details(err, nil))) os.Exit(1) } else { var bhModels Schema if err := bhInstance.Decode(&bhModels); err != nil { - slog.Error(fmt.Sprintf("Error: %v", errors.Details(err, nil))) + slog.Error("Failed to decode models", slog.String("err", errors.Details(err, nil))) os.Exit(1) } if err := GenerateGolang(projectRoot, bhModels); err != nil { - slog.Error(fmt.Sprintf("Error %v", err)) + slog.Error("Failed to generate Golang", attr.Error(err)) os.Exit(1) } if err := GenerateSharedTypeScript(projectRoot, bhModels); err != nil { - slog.Error(fmt.Sprintf("Error %v", err)) + slog.Error("Failed to generate TypeScript", attr.Error(err)) os.Exit(1) } if err := GenerateCSharp(projectRoot, bhModels); err != nil { - slog.Error(fmt.Sprintf("Error %v", err)) + slog.Error("Failed to generate CSharp", attr.Error(err)) + os.Exit(1) + } + + if err := GenerateSQL(projectRoot, bhModels); err != nil { + slog.Error("Failed to generate SQL", attr.Error(err)) os.Exit(1) } }