Skip to content
Merged
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
7 changes: 6 additions & 1 deletion cmd/server/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,12 @@ func NewHandlers(deps *HandlerDeps) routes.Handlers {

// Dashboard & Branch
Dashboard: handler.NewDashboardHandler(svc.Dashboard, log),
Branch: handler.NewBranchHandler(svc.Branch, v, log),
Branch: func() *handler.BranchHandler {
h := handler.NewBranchHandler(svc.Branch, v, log)
// S-2: wire AssetService so branch handler can verify repo ownership.
h.SetAssetService(svc.Asset)
return h
}(),

// Integration
Integration: handler.NewIntegrationHandler(svc.Integration, v, log),
Expand Down
44 changes: 44 additions & 0 deletions internal/app/asset/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,50 @@ func (s *ComponentService) DeleteAssetComponents(ctx context.Context, assetID st
return nil
}

// ListVulnerabilitiesByComponent returns the CVEs affecting a global component within a tenant.
// Powers the "Vulnerabilities" tab on the component detail sheet.
func (s *ComponentService) ListVulnerabilitiesByComponent(
ctx context.Context,
tenantID, componentID string,
includeResolved bool,
page, perPage int,
) (pagination.Result[componentdom.ComponentVulnerability], error) {
parsedTenantID, err := shared.IDFromString(tenantID)
if err != nil {
return pagination.Result[componentdom.ComponentVulnerability]{}, fmt.Errorf("%w: invalid tenant id format", shared.ErrValidation)
}
parsedComponentID, err := shared.IDFromString(componentID)
if err != nil {
return pagination.Result[componentdom.ComponentVulnerability]{}, fmt.Errorf("%w: invalid component id format", shared.ErrValidation)
}
p := pagination.New(page, perPage)
return s.repo.ListVulnerabilities(ctx, parsedTenantID, parsedComponentID, includeResolved, p)
}

// ListAssetUsageByComponent retrieves the assets within a tenant that use a given global component.
// Used by the "Used By Assets" blast-radius panel on the component detail sheet.
//
// When atRiskOnly is true, only assets with at least one open finding for this
// component are returned (matches the "at risk only" toggle in the UI).
func (s *ComponentService) ListAssetUsageByComponent(
ctx context.Context,
tenantID, componentID string,
atRiskOnly bool,
page, perPage int,
) (pagination.Result[componentdom.ComponentAssetUsage], error) {
parsedTenantID, err := shared.IDFromString(tenantID)
if err != nil {
return pagination.Result[componentdom.ComponentAssetUsage]{}, fmt.Errorf("%w: invalid tenant id format", shared.ErrValidation)
}
parsedComponentID, err := shared.IDFromString(componentID)
if err != nil {
return pagination.Result[componentdom.ComponentAssetUsage]{}, fmt.Errorf("%w: invalid component id format", shared.ErrValidation)
}

p := pagination.New(page, perPage)
return s.repo.ListAssetUsage(ctx, parsedTenantID, parsedComponentID, atRiskOnly, p)
}

// GetLicenseStats retrieves license statistics for a tenant.
func (s *ComponentService) GetLicenseStats(ctx context.Context, tenantID string) ([]componentdom.LicenseStats, error) {
parsedTenantID, err := shared.IDFromString(tenantID)
Expand Down
19 changes: 12 additions & 7 deletions internal/app/asset/group.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,20 +227,25 @@ func (s *AssetGroupService) UpdateAssetGroup(ctx context.Context, tenantIDStr st
group.SetTags(input.Tags)
}

if err := s.repo.Update(ctx, group); err != nil {
if err := s.repo.Update(ctx, tenantID, group); err != nil {
return nil, err
}

s.logger.Info("asset group updated", "id", id)
return group, nil
}

// DeleteAssetGroup deletes an asset group.
func (s *AssetGroupService) DeleteAssetGroup(ctx context.Context, id shared.ID) error {
if err := s.repo.Delete(ctx, id); err != nil {
// DeleteAssetGroup deletes an asset group within the given tenant scope.
// Security: tenantID required so the SQL DELETE is tenant-scoped (S-1 audit).
func (s *AssetGroupService) DeleteAssetGroup(ctx context.Context, tenantIDStr string, id shared.ID) error {
tenantID, err := shared.IDFromString(tenantIDStr)
if err != nil {
return fmt.Errorf("%w: invalid tenant id format", shared.ErrValidation)
}
if err := s.repo.Delete(ctx, tenantID, id); err != nil {
return err
}
s.logger.Info("asset group deleted", "id", id)
s.logger.Info("asset group deleted", "id", id, "tenant_id", tenantIDStr)
return nil
}

Expand Down Expand Up @@ -447,15 +452,15 @@ func (s *AssetGroupService) BulkUpdateAssetGroups(ctx context.Context, tenantID
}

// BulkDeleteAssetGroups deletes multiple asset groups.
func (s *AssetGroupService) BulkDeleteAssetGroups(ctx context.Context, groupIDs []string) (int, error) {
func (s *AssetGroupService) BulkDeleteAssetGroups(ctx context.Context, tenantIDStr string, groupIDs []string) (int, error) {
deleted := 0
for _, idStr := range groupIDs {
id, err := shared.IDFromString(idStr)
if err != nil {
continue
}

if err := s.DeleteAssetGroup(ctx, id); err != nil {
if err := s.DeleteAssetGroup(ctx, tenantIDStr, id); err != nil {
s.logger.Warn("bulk delete failed for group", "id", idStr, "error", err)
continue
}
Expand Down
62 changes: 52 additions & 10 deletions internal/app/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -649,12 +649,18 @@ type ExchangeTokenInput struct {
}

// ExchangeTokenResult represents the result of token exchange.
//
// RefreshToken is the NEW refresh token issued during rotation (S-3-rotate).
// Caller is expected to overwrite the old refresh_token cookie with this value
// — failing to do so means the next ExchangeToken call will fail (the old
// token is now marked used).
type ExchangeTokenResult struct {
AccessToken string
TenantID string
TenantSlug string
Role string
ExpiresAt time.Time
AccessToken string
RefreshToken string
TenantID string
TenantSlug string
Role string
ExpiresAt time.Time
}

// ExchangeToken exchanges a global refresh token for a tenant-scoped access token.
Expand Down Expand Up @@ -753,18 +759,54 @@ func (s *AuthService) ExchangeToken(ctx context.Context, input ExchangeTokenInpu
s.logger.Error("failed to update session activity", "error", err)
}

// S-3-rotate: rotate refresh token (mark old as used + issue new in same family).
// Matches the pattern used in CreateFirstTeam (line ~1300) and Refresh.
// Without rotation, a stolen refresh token remains valid for the full window;
// with rotation, theft is detected on the next legitimate use (token-already-used).
if err := storedToken.MarkUsed(); err != nil {
s.logger.Error("failed to mark refresh token as used", "error", err)
} else {
if err := s.refreshTokenRepo.Update(ctx, storedToken); err != nil {
s.logger.Error("failed to update refresh token", "error", err)
}
}

newRefreshTokenStr, _, err := s.tokenGenerator.GenerateGlobalRefreshToken(
u.ID().String(),
u.Email(),
u.Name(),
sess.ID().String(),
)
if err != nil {
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
}
newRefreshToken, err := sessiondom.NewRefreshTokenInFamily(
u.ID(),
sess.ID(),
newRefreshTokenStr,
storedToken.Family(),
s.config.RefreshTokenDuration,
)
if err != nil {
return nil, fmt.Errorf("failed to create refresh token: %w", err)
}
if err := s.refreshTokenRepo.Create(ctx, newRefreshToken); err != nil {
return nil, fmt.Errorf("failed to save refresh token: %w", err)
}

s.logger.Debug("token exchanged",
"user_id", u.ID().String(),
"tenant_id", input.TenantID,
"session_id", sess.ID().String(),
)

return &ExchangeTokenResult{
AccessToken: accessToken.AccessToken,
TenantID: accessToken.TenantID,
TenantSlug: accessToken.TenantSlug,
Role: accessToken.Role,
ExpiresAt: accessToken.ExpiresAt,
AccessToken: accessToken.AccessToken,
RefreshToken: newRefreshTokenStr,
TenantID: accessToken.TenantID,
TenantSlug: accessToken.TenantSlug,
Role: accessToken.Role,
ExpiresAt: accessToken.ExpiresAt,
}, nil
}

Expand Down
98 changes: 96 additions & 2 deletions internal/app/finding/vulnerability_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import (
"context"
"database/sql"
"fmt"
"strings"
"time"

"github.com/openctemio/api/internal/app/activity"
"github.com/openctemio/api/internal/app/aitriage"
"github.com/openctemio/api/internal/app/integration"
"strings"
"time"

"github.com/openctemio/api/internal/app/assignment"
"github.com/openctemio/api/internal/app/outbox"
Expand Down Expand Up @@ -195,6 +196,99 @@ func (s *VulnerabilityService) GetVulnerabilityByCVE(ctx context.Context, cveID
return s.vulnRepo.GetByCVE(ctx, cveID)
}

// ListAffectedAssets returns the assets in the tenant affected by a CVE
// (blast-radius reverse lookup). Powers the "Affected Assets" panel on the
// vulnerability detail sheet.
//
// includeResolved controls whether assets affected only by closed findings
// (resolved/false_positive/accepted) are included. Default is false (open only).
func (s *VulnerabilityService) ListAffectedAssets(
ctx context.Context,
tenantID, vulnID string,
includeResolved bool,
page, perPage int,
) (pagination.Result[vulnerability.VulnerabilityAffectedAsset], error) {
parsedTenant, err := shared.IDFromString(tenantID)
if err != nil {
return pagination.Result[vulnerability.VulnerabilityAffectedAsset]{},
fmt.Errorf("%w: invalid tenant id format", shared.ErrValidation)
}
parsedVuln, err := shared.IDFromString(vulnID)
if err != nil {
return pagination.Result[vulnerability.VulnerabilityAffectedAsset]{},
fmt.Errorf("%w: invalid vulnerability id format", shared.ErrValidation)
}

p := pagination.New(page, perPage)
return s.findingRepo.ListAffectedAssetsByVulnerabilityID(ctx, parsedTenant, parsedVuln, includeResolved, p)
}

// GetActiveCVEStats returns aggregate counts (total, by-severity, KEV, exploit)
// for the tenant's active CVEs. Powers the stat-card row above the Active CVEs
// table.
func (s *VulnerabilityService) GetActiveCVEStats(
ctx context.Context,
tenantID string,
includeResolved bool,
) (*vulnerability.ActiveCVEStats, error) {
parsedTenant, err := shared.IDFromString(tenantID)
if err != nil {
return nil, fmt.Errorf("%w: invalid tenant id format", shared.ErrValidation)
}
return s.findingRepo.GetActiveCVEStats(ctx, parsedTenant, includeResolved)
}

// ListActiveCVEsInput captures the query options for ListActiveCVEs.
type ListActiveCVEsInput struct {
TenantID string
IncludeResolved bool
SeverityIn []string
KEVOnly bool
MinCVSS *float64
MinEPSS *float64
ExploitAvailable *bool
Page int
PerPage int
}

// ListActiveCVEs returns CVEs currently impacting tenant assets (Active CVEs view).
// Distinct from the global CVE catalog — scoped to findings within tenant.
func (s *VulnerabilityService) ListActiveCVEs(
ctx context.Context,
input ListActiveCVEsInput,
) (pagination.Result[vulnerability.ActiveCVE], error) {
parsedTenant, err := shared.IDFromString(input.TenantID)
if err != nil {
return pagination.Result[vulnerability.ActiveCVE]{},
fmt.Errorf("%w: invalid tenant id format", shared.ErrValidation)
}
filter := vulnerability.ActiveCVEFilter{
IncludeResolved: input.IncludeResolved,
SeverityIn: input.SeverityIn,
KEVOnly: input.KEVOnly,
MinCVSS: input.MinCVSS,
MinEPSS: input.MinEPSS,
ExploitAvailable: input.ExploitAvailable,
}
p := pagination.New(input.Page, input.PerPage)
return s.findingRepo.ListActiveCVEsByTenant(ctx, parsedTenant, filter, p)
}

// ListAffectedAssetsByCVE is a convenience wrapper that resolves CVE-2024-XXXX
// to the global Vulnerability ID then delegates.
func (s *VulnerabilityService) ListAffectedAssetsByCVE(
ctx context.Context,
tenantID, cveID string,
includeResolved bool,
page, perPage int,
) (pagination.Result[vulnerability.VulnerabilityAffectedAsset], error) {
v, err := s.vulnRepo.GetByCVE(ctx, cveID)
if err != nil {
return pagination.Result[vulnerability.VulnerabilityAffectedAsset]{}, err
}
return s.ListAffectedAssets(ctx, tenantID, v.ID().String(), includeResolved, page, perPage)
}

// UpdateVulnerabilityInput represents the input for updating a vulnerability.
type UpdateVulnerabilityInput struct {
Title *string `validate:"omitempty,min=1,max=500"`
Expand Down
1 change: 1 addition & 0 deletions internal/app/finding_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ type (
KEVRepository = finding.KEVRepository
ListFindingsInput = finding.ListFindingsInput
ListVulnerabilitiesInput = finding.ListVulnerabilitiesInput
ListActiveCVEsInput = finding.ListActiveCVEsInput
PriorityAuditEntry = finding.PriorityAuditEntry
PriorityAuditRepository = finding.PriorityAuditRepository
PriorityChangeEvent = finding.PriorityChangeEvent
Expand Down
8 changes: 8 additions & 0 deletions internal/app/ingest/processor_components_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,14 @@ func (m *MockComponentRepository) GetLicenseStats(ctx context.Context, tenantID
return nil, nil
}

func (m *MockComponentRepository) ListAssetUsage(_ context.Context, _ shared.ID, _ shared.ID, _ bool, page pagination.Pagination) (pagination.Result[component.ComponentAssetUsage], error) {
return pagination.NewResult([]component.ComponentAssetUsage{}, 0, page), nil
}

func (m *MockComponentRepository) ListVulnerabilities(_ context.Context, _, _ shared.ID, _ bool, page pagination.Pagination) (pagination.Result[component.ComponentVulnerability], error) {
return pagination.NewResult([]component.ComponentVulnerability{}, 0, page), nil
}

func TestComponentProcessor_ProcessBatch_WithLicenses(t *testing.T) {
// Setup
mockRepo := new(MockComponentRepository)
Expand Down
9 changes: 9 additions & 0 deletions internal/app/ingest/processor_findings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1190,6 +1190,15 @@ func (s *stubFindingRepository) ListByVulnerabilityID(_ context.Context, _, _ sh
func (s *stubFindingRepository) ListByComponentID(_ context.Context, _, _ shared.ID, _ vulnerability.FindingListOptions, _ pagination.Pagination) (pagination.Result[*vulnerability.Finding], error) {
return pagination.Result[*vulnerability.Finding]{}, nil
}
func (s *stubFindingRepository) ListAffectedAssetsByVulnerabilityID(_ context.Context, _, _ shared.ID, _ bool, _ pagination.Pagination) (pagination.Result[vulnerability.VulnerabilityAffectedAsset], error) {
return pagination.Result[vulnerability.VulnerabilityAffectedAsset]{}, nil
}
func (s *stubFindingRepository) ListActiveCVEsByTenant(_ context.Context, _ shared.ID, _ vulnerability.ActiveCVEFilter, _ pagination.Pagination) (pagination.Result[vulnerability.ActiveCVE], error) {
return pagination.Result[vulnerability.ActiveCVE]{}, nil
}
func (s *stubFindingRepository) GetActiveCVEStats(_ context.Context, _ shared.ID, _ bool) (*vulnerability.ActiveCVEStats, error) {
return &vulnerability.ActiveCVEStats{BySeverity: map[string]int{}}, nil
}
func (s *stubFindingRepository) Count(_ context.Context, _ vulnerability.FindingFilter) (int64, error) {
return 0, nil
}
Expand Down
Loading
Loading