Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
29da793
environment added to opengraphschema service + principal kinds - lots…
kpowderly Jan 6, 2026
a7f351e
did an overhaul of everything after talking with Cody and pulled a co…
kpowderly Jan 7, 2026
bc97cb0
cleanup
kpowderly Jan 7, 2026
bce213b
transactions that hopefully satisfy both databases
kpowderly Jan 7, 2026
479fb18
Merge remote-tracking branch 'origin/main' into BED-6852-environment
kpowderly Jan 7, 2026
89998ee
just prepare
kpowderly Jan 7, 2026
6ded4e3
abandoned the transactor
kpowderly Jan 7, 2026
6c08285
abandoned the transactor
kpowderly Jan 7, 2026
4ae5dfb
entry pointed corrected so transactions can live on the interface aga…
kpowderly Jan 8, 2026
a3d9422
some cleanup
kpowderly Jan 8, 2026
5987407
feat(graphschema): Add Environment principal kinds BED-7076
cweidenkeller Dec 29, 2025
e76b52d
pretty decent sized refactor to move from service layer to database l…
kpowderly Jan 9, 2026
75b6804
Merge remote-tracking branch 'origin/BED-7076' into BED-6852-environment
kpowderly Jan 9, 2026
38186be
pulled in Conrad's changes
kpowderly Jan 9, 2026
ab74bf1
code rabbit comments
kpowderly Jan 9, 2026
d63427f
updated to add integration flag
kpowderly Jan 9, 2026
bad51d2
missed an arg
kpowderly Jan 9, 2026
6e4ba47
peer review changes
kpowderly Jan 9, 2026
f94cb7c
Merge remote-tracking branch 'origin/main' into BED-6852-environment
kpowderly Jan 9, 2026
3c5f688
Merge remote-tracking branch 'origin/main' into BED-6852-environment
kpowderly Jan 13, 2026
85b9407
merge conflicts
kpowderly Jan 13, 2026
be02063
updated to be in line with other PR
kpowderly Jan 13, 2026
094d52a
just prepare
kpowderly Jan 13, 2026
be0c964
just prepare
kpowderly Jan 13, 2026
86560a5
updated error message
kpowderly Jan 14, 2026
434fb40
Merge remote-tracking branch 'origin/main' into BED-6852-environment
kpowderly Jan 14, 2026
1cab880
peer review changes
kpowderly Jan 15, 2026
b9cbb50
Merge remote-tracking branch 'origin/main' into BED-6852-environment
kpowderly Jan 15, 2026
471fc4e
peer review changes - more naming
kpowderly Jan 15, 2026
1de71fc
migration update
kpowderly Jan 15, 2026
a2427b6
migration update
kpowderly Jan 15, 2026
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
3 changes: 2 additions & 1 deletion cmd/api/src/api/registration/registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ func RegisterFossRoutes(
authorizer auth.Authorizer,
ingestSchema upload.IngestSchema,
dogtagsService dogtags.Service,
openGraphSchemaService v2.OpenGraphSchemaService,
) {
router.With(func() mux.MiddlewareFunc {
return middleware.DefaultRateLimitMiddleware(rdms)
Expand All @@ -82,6 +83,6 @@ func RegisterFossRoutes(
routerInst.PathPrefix("/ui", static.AssetHandler),
)

var resources = v2.NewResources(rdms, graphDB, cfg, apiCache, graphQuery, collectorManifests, authorizer, authenticator, ingestSchema, dogtagsService)
var resources = v2.NewResources(rdms, graphDB, cfg, apiCache, graphQuery, collectorManifests, authorizer, authenticator, ingestSchema, dogtagsService, openGraphSchemaService)
NewV2API(resources, routerInst)
}
2 changes: 2 additions & 0 deletions cmd/api/src/api/registration/v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,5 +367,7 @@ func NewV2API(resources v2.Resources, routerInst *router.Router) {
routerInst.POST("/api/v2/custom-nodes", resources.CreateCustomNodeKind).RequireAuth(),
routerInst.PUT(fmt.Sprintf("/api/v2/custom-nodes/{%s}", v2.CustomNodeKindParameter), resources.UpdateCustomNodeKind).RequireAuth(),
routerInst.DELETE(fmt.Sprintf("/api/v2/custom-nodes/{%s}", v2.CustomNodeKindParameter), resources.DeleteCustomNodeKind).RequireAuth(),

routerInst.PUT("/api/v2/extensions", resources.OpenGraphSchemaIngest),
)
}
72 changes: 72 additions & 0 deletions cmd/api/src/api/v2/mocks/graphschemaextensions.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions cmd/api/src/api/v2/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ type Resources struct {
Authenticator api.Authenticator
IngestSchema upload.IngestSchema
FileService fs.Service
openGraphSchemaService OpenGraphSchemaService
DogTags dogtags.Service
}

Expand All @@ -130,6 +131,7 @@ func NewResources(
authenticator api.Authenticator,
ingestSchema upload.IngestSchema,
dogtagsService dogtags.Service,
openGraphSchemaService OpenGraphSchemaService,
) Resources {
return Resources{
Decoder: schema.NewDecoder(),
Expand All @@ -145,5 +147,6 @@ func NewResources(
IngestSchema: ingestSchema,
FileService: &fs.Client{},
DogTags: dogtagsService,
openGraphSchemaService: openGraphSchemaService,
}
}
60 changes: 60 additions & 0 deletions cmd/api/src/api/v2/opengraphschema.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// 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
package v2

import (
"context"
"encoding/json"
"fmt"
"net/http"

"github.com/specterops/bloodhound/cmd/api/src/api"
)

//go:generate go run go.uber.org/mock/mockgen -copyright_file ../../../../../LICENSE.header -destination=./mocks/graphschemaextensions.go -package=mocks . OpenGraphSchemaService
type OpenGraphSchemaService interface {
UpsertGraphSchemaExtension(ctx context.Context, req GraphSchemaExtension) error
}

type GraphSchemaExtension struct {
Environments []Environment `json:"environments"`
}

type Environment struct {
EnvironmentKind string `json:"environmentKind"`
SourceKind string `json:"sourceKind"`
PrincipalKinds []string `json:"principalKinds"`
}

// TODO: Implement this - skeleton endpoint to simply test the handler.
func (s Resources) OpenGraphSchemaIngest(response http.ResponseWriter, request *http.Request) {
var (
ctx = request.Context()
)

var req GraphSchemaExtension
if err := json.NewDecoder(request.Body).Decode(&req); err != nil {
Copy link
Contributor

@brandonshearin brandonshearin Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you know if we plan to use JSONSchema in the API handler, similar to what is done for OpenGraph ingest? curious if lawson is using it for his work. we may want to reject bad requests with 400 at the API layer but that may be coming in a follow up?

just leaving this as a note here so i dont forget but lemme know

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like Lawson is doing something similar..

I was kind of basing my work off of this example in the RFC.

Side note (and probably a question for my other PR) but do you know if findings and remediations get uploaded in the same way? I guess I'm not sure what the use case is for this handler.

api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, api.ErrorResponsePayloadUnmarshalError, request), response)
return
}

if err := s.openGraphSchemaService.UpsertGraphSchemaExtension(ctx, req); err != nil {
api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error upserting graph schema extension: %v", err), request), response)
return
}

response.WriteHeader(http.StatusCreated)
}
3 changes: 3 additions & 0 deletions cmd/api/src/database/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ type Database interface {

// OpenGraph Schema
OpenGraphSchema

// Kind
Kind
}

type BloodhoundDB struct {
Expand Down
69 changes: 44 additions & 25 deletions cmd/api/src/database/graphschema.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,11 @@ type OpenGraphSchema interface {

GetGraphSchemaEdgeKindsWithSchemaName(ctx context.Context, edgeKindFilters model.Filters, sort model.Sort, skip, limit int) (model.GraphSchemaEdgeKindsWithNamedSchema, int, error)

CreateSchemaEnvironment(ctx context.Context, extensionId int32, environmentKindId int32, sourceKindId int32) (model.SchemaEnvironment, error)
GetSchemaEnvironmentById(ctx context.Context, environmentId int32) (model.SchemaEnvironment, error)
GetSchemaEnvironments(ctx context.Context) ([]model.SchemaEnvironment, error)
DeleteSchemaEnvironment(ctx context.Context, environmentId int32) error
CreateEnvironment(ctx context.Context, extensionId int32, environmentKindId int32, sourceKindId int32) (model.SchemaEnvironment, error)
GetEnvironmentByKinds(ctx context.Context, environmentKindId, sourceKindId int32) (model.SchemaEnvironment, error)
GetEnvironmentById(ctx context.Context, environmentId int32) (model.SchemaEnvironment, error)
GetEnvironments(ctx context.Context) ([]model.SchemaEnvironment, error)
DeleteEnvironment(ctx context.Context, environmentId int32) error

CreateSchemaRelationshipFinding(ctx context.Context, extensionId int32, relationshipKindId int32, environmentId int32, name string, displayName string) (model.SchemaRelationshipFinding, error)
GetSchemaRelationshipFindingById(ctx context.Context, findingId int32) (model.SchemaRelationshipFinding, error)
Expand All @@ -65,9 +66,10 @@ type OpenGraphSchema interface {
GetRemediationByFindingId(ctx context.Context, findingId int32) (model.Remediation, error)
UpdateRemediation(ctx context.Context, findingId int32, shortDescription string, longDescription string, shortRemediation string, longRemediation string) (model.Remediation, error)
DeleteRemediation(ctx context.Context, findingId int32) error
CreateSchemaEnvironmentPrincipalKind(ctx context.Context, environmentId int32, principalKind int32) (model.SchemaEnvironmentPrincipalKind, error)
GetSchemaEnvironmentPrincipalKindsByEnvironmentId(ctx context.Context, environmentId int32) (model.SchemaEnvironmentPrincipalKinds, error)
DeleteSchemaEnvironmentPrincipalKind(ctx context.Context, environmentId int32, principalKind int32) error

CreatePrincipalKind(ctx context.Context, environmentId int32, principalKind int32) (model.SchemaEnvironmentPrincipalKind, error)
GetPrincipalKindsByEnvironmentId(ctx context.Context, environmentId int32) (model.SchemaEnvironmentPrincipalKinds, error)
DeletePrincipalKind(ctx context.Context, environmentId int32, principalKind int32) error
}

const (
Expand Down Expand Up @@ -239,10 +241,10 @@ func (s *BloodhoundDB) GetGraphSchemaNodeKinds(ctx context.Context, filters mode
if filterAndPagination, err := parseFiltersAndPagination(filters, sort, 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,
sqlStr := fmt.Sprintf(`SELECT nk.id, k.name, nk.schema_extension_id, nk.display_name, nk.description,
nk.is_display_kind, nk.icon, nk.icon_color, nk.created_at, nk.updated_at, nk.deleted_at
FROM %s nk
JOIN %s k ON nk.kind_id = k.id
JOIN %s k ON nk.kind_id = k.id
%s %s %s`,
model.GraphSchemaNodeKind{}.TableName(), kindTable, filterAndPagination.WhereClause, filterAndPagination.OrderSql, filterAndPagination.SkipLimit)
if result := s.db.WithContext(ctx).Raw(sqlStr, filterAndPagination.Filter.params...).Scan(&schemaNodeKinds); result.Error != nil {
Expand Down Expand Up @@ -288,7 +290,7 @@ func (s *BloodhoundDB) UpdateGraphSchemaNodeKind(ctx context.Context, schemaNode
RETURNING id, kind_id, schema_extension_id, display_name, description, is_display_kind, icon, icon_color, created_at, updated_at, deleted_at
)
SELECT updated_row.id, %s.name, schema_extension_id, display_name, description, is_display_kind, icon, icon_color, created_at, updated_at, deleted_at
FROM updated_row
FROM updated_row
JOIN %s ON %s.id = updated_row.kind_id`,
schemaNodeKind.TableName(), kindTable, kindTable, kindTable), schemaNodeKind.SchemaExtensionId,
schemaNodeKind.DisplayName, schemaNodeKind.Description, schemaNodeKind.IsDisplayKind, schemaNodeKind.Icon,
Expand Down Expand Up @@ -436,7 +438,7 @@ func (s *BloodhoundDB) CreateGraphSchemaEdgeKind(ctx context.Context, name strin
RETURNING id, kind_id, schema_extension_id, description, is_traversable, created_at, updated_at, deleted_at
)
SELECT ie.id, ie.schema_extension_id, dk.name, ie.description, ie.is_traversable, ie.created_at, ie.updated_at, ie.deleted_at
FROM inserted_edges ie
FROM inserted_edges ie
JOIN dawgs_kind dk ON ie.kind_id = dk.id;`, name, schemaExtensionId, description, isTraversable).Scan(&schemaEdgeKind); result.Error != nil {
if strings.Contains(result.Error.Error(), DuplicateKeyValueErrorString) {
return schemaEdgeKind, fmt.Errorf("%w: %v", ErrDuplicateSchemaEdgeKindName, result.Error)
Expand All @@ -457,10 +459,10 @@ func (s *BloodhoundDB) GetGraphSchemaEdgeKinds(ctx context.Context, edgeKindFilt
if filterAndPagination, err := parseFiltersAndPagination(edgeKindFilters, sort, 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,
sqlStr := fmt.Sprintf(`SELECT ek.id, k.name, ek.schema_extension_id, ek.description, ek.is_traversable,
ek.created_at, ek.updated_at, ek.deleted_at
FROM %s ek
JOIN %s k ON ek.kind_id = k.id
JOIN %s k ON ek.kind_id = k.id
%s %s %s`,
model.GraphSchemaEdgeKind{}.TableName(), kindTable, filterAndPagination.WhereClause,
filterAndPagination.OrderSql, filterAndPagination.SkipLimit)
Expand Down Expand Up @@ -542,7 +544,7 @@ func (s *BloodhoundDB) UpdateGraphSchemaEdgeKind(ctx context.Context, schemaEdge
SET schema_extension_id = ?, description = ?, is_traversable = ?, updated_at = NOW()
WHERE id = ?
RETURNING id, kind_id, schema_extension_id, description, is_traversable, created_at, updated_at, deleted_at
)
)
SELECT updated_row.id, %s.name, schema_extension_id, description, is_traversable, created_at, updated_at, deleted_at
FROM updated_row
JOIN %s ON %s.id = updated_row.kind_id`,
Expand Down Expand Up @@ -570,8 +572,8 @@ func (s *BloodhoundDB) DeleteGraphSchemaEdgeKind(ctx context.Context, schemaEdge
return nil
}

// CreateSchemaEnvironment - creates a new schema_environment.
func (s *BloodhoundDB) CreateSchemaEnvironment(ctx context.Context, extensionId int32, environmentKindId int32, sourceKindId int32) (model.SchemaEnvironment, error) {
// CreateEnvironment - creates a new schema_environment.
func (s *BloodhoundDB) CreateEnvironment(ctx context.Context, extensionId int32, environmentKindId int32, sourceKindId int32) (model.SchemaEnvironment, error) {
var schemaEnvironment model.SchemaEnvironment

if result := s.db.WithContext(ctx).Raw(fmt.Sprintf(`
Expand All @@ -588,14 +590,30 @@ func (s *BloodhoundDB) CreateSchemaEnvironment(ctx context.Context, extensionId
return schemaEnvironment, nil
}

// GetSchemaEnvironments - retrieves list of schema environments.
func (s *BloodhoundDB) GetSchemaEnvironments(ctx context.Context) ([]model.SchemaEnvironment, error) {
// GetEnvironments - retrieves list of schema environments.
func (s *BloodhoundDB) GetEnvironments(ctx context.Context) ([]model.SchemaEnvironment, error) {
var result []model.SchemaEnvironment
return result, CheckError(s.db.WithContext(ctx).Find(&result))
}

// GetSchemaEnvironmentById - retrieves a schema environment by id.
func (s *BloodhoundDB) GetSchemaEnvironmentById(ctx context.Context, environmentId int32) (model.SchemaEnvironment, error) {
// GetEnvironmentByKinds - retrieves an environment by its environment kind and source kind.
func (s *BloodhoundDB) GetEnvironmentByKinds(ctx context.Context, environmentKindId, sourceKindId int32) (model.SchemaEnvironment, error) {
var env model.SchemaEnvironment

if result := s.db.WithContext(ctx).Raw(
"SELECT * FROM schema_environments WHERE environment_kind_id = ? AND source_kind_id = ? AND deleted_at IS NULL",
environmentKindId, sourceKindId,
).Scan(&env); result.Error != nil {
return model.SchemaEnvironment{}, CheckError(result)
} else if result.RowsAffected == 0 {
return model.SchemaEnvironment{}, ErrNotFound
}

return env, nil
}

// GetEnvironmentById - retrieves a schema environment by id.
func (s *BloodhoundDB) GetEnvironmentById(ctx context.Context, environmentId int32) (model.SchemaEnvironment, error) {
var schemaEnvironment model.SchemaEnvironment

if result := s.db.WithContext(ctx).Raw(fmt.Sprintf(`
Expand All @@ -611,8 +629,8 @@ func (s *BloodhoundDB) GetSchemaEnvironmentById(ctx context.Context, environment
return schemaEnvironment, nil
}

// DeleteSchemaEnvironment - deletes a schema environment by id.
func (s *BloodhoundDB) DeleteSchemaEnvironment(ctx context.Context, environmentId int32) error {
// DeleteEnvironment - deletes a schema environment by id.
func (s *BloodhoundDB) DeleteEnvironment(ctx context.Context, environmentId int32) error {
var schemaEnvironment model.SchemaEnvironment

if result := s.db.WithContext(ctx).Exec(fmt.Sprintf(`DELETE FROM %s WHERE id = ?`, schemaEnvironment.TableName()), environmentId); result.Error != nil {
Expand Down Expand Up @@ -767,7 +785,7 @@ func (s *BloodhoundDB) DeleteRemediation(ctx context.Context, findingId int32) e
return nil
}

func (s *BloodhoundDB) CreateSchemaEnvironmentPrincipalKind(ctx context.Context, environmentId int32, principalKind int32) (model.SchemaEnvironmentPrincipalKind, error) {
func (s *BloodhoundDB) CreatePrincipalKind(ctx context.Context, environmentId int32, principalKind int32) (model.SchemaEnvironmentPrincipalKind, error) {
var envPrincipalKind model.SchemaEnvironmentPrincipalKind

if result := s.db.WithContext(ctx).Raw(`
Expand All @@ -781,7 +799,8 @@ func (s *BloodhoundDB) CreateSchemaEnvironmentPrincipalKind(ctx context.Context,
return envPrincipalKind, nil
}

func (s *BloodhoundDB) GetSchemaEnvironmentPrincipalKindsByEnvironmentId(ctx context.Context, environmentId int32) (model.SchemaEnvironmentPrincipalKinds, error) {
// GetPrincipalKindsByEnvironmentID - retrieves a schema environments principal kind by environment id.
func (s *BloodhoundDB) GetPrincipalKindsByEnvironmentId(ctx context.Context, environmentId int32) (model.SchemaEnvironmentPrincipalKinds, error) {
var envPrincipalKinds model.SchemaEnvironmentPrincipalKinds

if result := s.db.WithContext(ctx).Raw(`
Expand All @@ -795,7 +814,7 @@ func (s *BloodhoundDB) GetSchemaEnvironmentPrincipalKindsByEnvironmentId(ctx con
return envPrincipalKinds, nil
}

func (s *BloodhoundDB) DeleteSchemaEnvironmentPrincipalKind(ctx context.Context, environmentId int32, principalKind int32) error {
func (s *BloodhoundDB) DeletePrincipalKind(ctx context.Context, environmentId int32, principalKind int32) error {
if result := s.db.WithContext(ctx).Exec(`
DELETE FROM schema_environments_principal_kinds
WHERE environment_id = ? AND principal_kind = ?`,
Expand Down
Loading