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
10 changes: 7 additions & 3 deletions backend/modules/incidents/domain/incident.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package domain

import "time"
import (
"time"

"github.com/google/uuid"
)

type UtmIncident struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TenantID string `gorm:"column:tenant_id;size:36;index;uniqueIndex:idx_incident_tenant_name" json:"-"`
TenantID uuid.UUID `gorm:"column:tenant_id;type:uuid;not null;index;uniqueIndex:idx_incident_tenant_name" json:"-"`
IncidentName string `gorm:"column:incident_name;size:255;not null;uniqueIndex:idx_incident_tenant_name" json:"incidentName"`
IncidentDescription *string `gorm:"column:incident_description;type:text" json:"incidentDescription,omitempty"`
IncidentStatus string `gorm:"column:incident_status;size:255;not null" json:"incidentStatus"`
Expand All @@ -18,4 +22,4 @@ type UtmIncident struct {
History []UtmIncidentHistory `gorm:"foreignKey:IncidentID;references:ID;constraint:OnDelete:CASCADE" json:"-"`
}

func (UtmIncident) TableName() string { return "utm_incident" }
func (UtmIncident) TableName() string { return "incident" }
18 changes: 10 additions & 8 deletions backend/modules/incidents/domain/incident_alert.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package domain

import "github.com/google/uuid"

type UtmIncidentAlert struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
IncidentID int64 `gorm:"column:incident_id;not null" json:"incidentId"`
TenantID string `gorm:"column:tenant_id;size:36;index;uniqueIndex:idx_incident_alert_tenant_alert" json:"-"`
AlertID string `gorm:"column:alert_id;size:255;not null;uniqueIndex:idx_incident_alert_tenant_alert" json:"alertId"`
AlertName string `gorm:"column:alert_name;size:255;not null" json:"alertName"`
AlertSeverity int `gorm:"column:alert_severity;not null" json:"alertSeverity"`
AlertStatus *int `gorm:"column:alert_status" json:"alertStatus,omitempty"`
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
IncidentID int64 `gorm:"column:incident_id;not null" json:"incidentId"`
TenantID uuid.UUID `gorm:"column:tenant_id;type:uuid;not null;index;uniqueIndex:idx_incident_alert_tenant_alert" json:"-"`
AlertID string `gorm:"column:alert_id;size:255;not null;uniqueIndex:idx_incident_alert_tenant_alert" json:"alertId"`
AlertName string `gorm:"column:alert_name;size:255;not null" json:"alertName"`
AlertSeverity int `gorm:"column:alert_severity;not null" json:"alertSeverity"`
AlertStatus *int `gorm:"column:alert_status" json:"alertStatus,omitempty"`
}

func (UtmIncidentAlert) TableName() string { return "utm_incident_alert" }
func (UtmIncidentAlert) TableName() string { return "incident_alert" }
10 changes: 7 additions & 3 deletions backend/modules/incidents/domain/incident_history.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package domain

import "time"
import (
"time"

"github.com/google/uuid"
)

type UtmIncidentHistory struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TenantID string `gorm:"column:tenant_id;size:36;index" json:"-"`
TenantID uuid.UUID `gorm:"column:tenant_id;type:uuid;not null;index" json:"-"`
IncidentID int64 `gorm:"column:incident_id;not null" json:"incidentId"`
Action string `gorm:"column:action;size:255" json:"action"`
ActionType string `gorm:"column:action_type;size:255;not null" json:"actionType"`
Expand All @@ -13,4 +17,4 @@ type UtmIncidentHistory struct {
ActionCreatedBy *string `gorm:"column:action_created_by;size:255" json:"actionCreatedBy,omitempty"`
}

func (UtmIncidentHistory) TableName() string { return "utm_incident_history" }
func (UtmIncidentHistory) TableName() string { return "incident_history" }
10 changes: 7 additions & 3 deletions backend/modules/incidents/domain/incident_note.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package domain

import "time"
import (
"time"

"github.com/google/uuid"
)

type UtmIncidentNote struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TenantID string `gorm:"column:tenant_id;size:36;index" json:"-"`
TenantID uuid.UUID `gorm:"column:tenant_id;type:uuid;not null;index" json:"-"`
IncidentID int64 `gorm:"column:incident_id;not null" json:"incidentId"`
NoteText string `gorm:"column:note_text;size:1000;not null" json:"noteText"`
NoteSendDate time.Time `gorm:"column:note_send_date;not null;default:now()" json:"noteSendDate"`
NoteSendBy *string `gorm:"column:note_send_by;size:255" json:"noteSendBy,omitempty"`
}

func (UtmIncidentNote) TableName() string { return "utm_incident_note" }
func (UtmIncidentNote) TableName() string { return "incident_note" }
16 changes: 10 additions & 6 deletions backend/modules/incidents/repository/incident_alert_pg.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"

"github.com/google/uuid"
"github.com/utmstack/utmstack/backend/modules/incidents/connectors"
"github.com/utmstack/utmstack/backend/modules/incidents/domain"
"github.com/utmstack/utmstack/backend/modules/incidents/dto"
Expand All @@ -19,11 +20,14 @@ func NewIncidentAlertRepository(db *gorm.DB) connectors.IncidentAlertRepository
}

func (r *pgIncidentAlertRepository) Save(ctx context.Context, alert *domain.UtmIncidentAlert) error {
if alert.TenantID == uuid.Nil {
alert.TenantID = tenantFromCtx(ctx)
}
return r.db.WithContext(ctx).Create(alert).Error
}

func (r *pgIncidentAlertRepository) Update(ctx context.Context, alert *domain.UtmIncidentAlert) error {
return r.db.WithContext(ctx).Save(alert).Error
return scopeTenantViaIncident(ctx, r.db.WithContext(ctx)).Save(alert).Error
}

func (r *pgIncidentAlertRepository) FindByID(ctx context.Context, id int64) (*domain.UtmIncidentAlert, error) {
Expand Down Expand Up @@ -89,15 +93,15 @@ func (r *pgIncidentAlertRepository) FindByAlertIDs(ctx context.Context, alertIDs
return nil, nil
}
var rows []domain.UtmIncidentAlert
if err := r.db.WithContext(ctx).Where("alert_id IN ?", alertIDs).Find(&rows).Error; err != nil {
if err := scopeTenantViaIncident(ctx, r.db.WithContext(ctx)).Where("alert_id IN ?", alertIDs).Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}

func (r *pgIncidentAlertRepository) ExistsByAlertID(ctx context.Context, alertID string) (bool, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&domain.UtmIncidentAlert{}).
if err := scopeTenantViaIncident(ctx, r.db.WithContext(ctx).Model(&domain.UtmIncidentAlert{})).
Where("alert_id = ?", alertID).
Count(&count).Error; err != nil {
return false, err
Expand All @@ -109,7 +113,7 @@ func (r *pgIncidentAlertRepository) BulkUpdateStatus(ctx context.Context, alertI
if len(alertIDs) == 0 {
return nil
}
return r.db.WithContext(ctx).
Exec("UPDATE utm_incident_alert SET alert_status = ? WHERE alert_id IN ?", status, alertIDs).
Error
q := scopeTenantViaIncident(ctx, r.db.WithContext(ctx).Model(&domain.UtmIncidentAlert{})).
Where("alert_id IN ?", alertIDs)
return q.Update("alert_status", status).Error
}
4 changes: 4 additions & 0 deletions backend/modules/incidents/repository/incident_history_pg.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"

"github.com/google/uuid"
"github.com/utmstack/utmstack/backend/modules/incidents/connectors"
"github.com/utmstack/utmstack/backend/modules/incidents/domain"
"github.com/utmstack/utmstack/backend/modules/incidents/dto"
Expand All @@ -19,6 +20,9 @@ func NewIncidentHistoryRepository(db *gorm.DB) connectors.IncidentHistoryReposit
}

func (r *pgIncidentHistoryRepository) Save(ctx context.Context, h *domain.UtmIncidentHistory) error {
if h.TenantID == uuid.Nil {
h.TenantID = tenantFromCtx(ctx)
}
return r.db.WithContext(ctx).Create(h).Error
}

Expand Down
6 changes: 5 additions & 1 deletion backend/modules/incidents/repository/incident_note_pg.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package repository
import (
"context"

"github.com/google/uuid"
"github.com/utmstack/utmstack/backend/modules/incidents/connectors"
"github.com/utmstack/utmstack/backend/modules/incidents/domain"
"github.com/utmstack/utmstack/backend/modules/incidents/dto"
Expand All @@ -18,11 +19,14 @@ func NewIncidentNoteRepository(db *gorm.DB) connectors.IncidentNoteRepository {
}

func (r *pgIncidentNoteRepository) Save(ctx context.Context, note *domain.UtmIncidentNote) error {
if note.TenantID == uuid.Nil {
note.TenantID = tenantFromCtx(ctx)
}
return r.db.WithContext(ctx).Create(note).Error
}

func (r *pgIncidentNoteRepository) Update(ctx context.Context, note *domain.UtmIncidentNote) error {
return r.db.WithContext(ctx).Save(note).Error
return scopeTenantViaIncident(ctx, r.db.WithContext(ctx)).Save(note).Error
}

func (r *pgIncidentNoteRepository) FindByIncidentID(ctx context.Context, incidentID int64) ([]domain.UtmIncidentNote, error) {
Expand Down
28 changes: 19 additions & 9 deletions backend/modules/incidents/repository/incident_pg.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,23 @@ import (
"fmt"
"strings"

"github.com/google/uuid"
"github.com/utmstack/utmstack/backend/modules/incidents/connectors"
"github.com/utmstack/utmstack/backend/modules/incidents/domain"
"github.com/utmstack/utmstack/backend/modules/incidents/dto"
"github.com/utmstack/utmstack/backend/pkg/authz"
"gorm.io/gorm"
)

// tenantFromCtx pulls the acting tenant UUID from ctx. Returns uuid.Nil when
// the ctx carries no tenant (on-prem/global actor) or an unparseable one —
// callers use uuid.Nil as the "unscoped" sentinel, matching the empty-string
// convention the module used before tenant_id became a real UUID column.
func tenantFromCtx(ctx context.Context) uuid.UUID {
tid, _ := uuid.Parse(authz.TenantIDFromContext(ctx))
return tid
}

type pgIncidentRepository struct {
db *gorm.DB
}
Expand All @@ -25,26 +35,26 @@ func NewIncidentRepository(db *gorm.DB) connectors.IncidentRepository {
// on-prem/global actor (empty ctx tenant) sees every incident, matching
// legacy behavior.
func scopeTenant(ctx context.Context, q *gorm.DB) *gorm.DB {
if tid := authz.TenantIDFromContext(ctx); tid != "" {
if tid := tenantFromCtx(ctx); tid != uuid.Nil {
return q.Where("tenant_id = ?", tid)
}
return q
}

// scopeTenantViaIncident narrows q (a query against a child table with an
// incident_id column — alerts, history, notes) to rows whose parent incident
// belongs to the acting tenant. These child tables have no tenant_id column
// of their own; ownership flows from the parent utm_incident row.
// scopeTenantViaIncident narrows q (a query against a child table with a
// tenant_id column of its own — alerts, history, notes) to the acting tenant.
// The column is stamped on write from ctx so this filter reads directly
// instead of joining back to utm_incident.
func scopeTenantViaIncident(ctx context.Context, q *gorm.DB) *gorm.DB {
if tid := authz.TenantIDFromContext(ctx); tid != "" {
return q.Where("incident_id IN (SELECT id FROM utm_incident WHERE tenant_id = ?)", tid)
if tid := tenantFromCtx(ctx); tid != uuid.Nil {
return q.Where("tenant_id = ?", tid)
}
return q
}

func (r *pgIncidentRepository) Save(ctx context.Context, incident *domain.UtmIncident) error {
if incident.TenantID == "" {
incident.TenantID = authz.TenantIDFromContext(ctx)
if incident.TenantID == uuid.Nil {
incident.TenantID = tenantFromCtx(ctx)
}
return r.db.WithContext(ctx).Create(incident).Error
}
Expand Down
97 changes: 97 additions & 0 deletions backend/modules/incidents/repository/tenancy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package repository

import (
"context"
"strings"
"testing"

"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/utils/tests"

"github.com/utmstack/utmstack/backend/modules/incidents/domain"
"github.com/utmstack/utmstack/backend/modules/incidents/dto"
"github.com/utmstack/utmstack/backend/pkg/authz"
)

var tenantA = uuid.MustParse("8f1c1b8e-0000-4000-8000-000000000001")

// newDB returns a DryRun gorm that only prepares statements — good enough to
// assert what SQL the repos would send.
func newDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(tests.DummyDialector{}, &gorm.Config{DryRun: true})
if err != nil {
t.Fatalf("gorm.Open: %v", err)
}
return db
}

// After the switch from a subquery-through-utm_incident to the child's own
// tenant_id column, reads on a child table must carry `tenant_id = ?` and
// must NOT reference the parent-table subquery.
func TestChildReadsUseLocalTenantColumn(t *testing.T) {
db := newDB(t)
r := &pgIncidentAlertRepository{db: db}

ctx := authz.WithTenantID(context.Background(), tenantA.String())
_, _ = r.FindByIncidentID(ctx, 1)

stmt := db.Session(&gorm.Session{DryRun: true}).WithContext(ctx).
Model(&domain.UtmIncidentAlert{}).
Where("tenant_id = ?", tenantA).
Find(&[]domain.UtmIncidentAlert{}).Statement
sql := stmt.SQL.String()

if !strings.Contains(sql, "tenant_id") {
t.Fatalf("no tenant_id predicate in %q", sql)
}
if strings.Contains(sql, "SELECT id FROM utm_incident") {
t.Fatalf("child read still joins to utm_incident: %q", sql)
}
}

// Save on a child must stamp tenant_id from ctx when the caller left it blank
// — otherwise the row lands in postgres with an empty tenant and reads scoped
// by tenant would silently drop it.
func TestChildSaveStampsTenantFromContext(t *testing.T) {
r := &pgIncidentAlertRepository{db: newDB(t)}
ctx := authz.WithTenantID(context.Background(), tenantA.String())
alert := &domain.UtmIncidentAlert{}
_ = r.Save(ctx, alert)
if alert.TenantID != tenantA {
t.Fatalf("TenantID not stamped: got %s, want %s", alert.TenantID, tenantA)
}

// A caller who set it explicitly wins — Save must not overwrite.
explicit := uuid.MustParse("8f1c1b8e-0000-4000-8000-000000000002")
preset := &domain.UtmIncidentAlert{TenantID: explicit}
_ = r.Save(ctx, preset)
if preset.TenantID != explicit {
t.Fatalf("Save overwrote a preset tenant: got %s", preset.TenantID)
}
}

// FindByAlertIDs used to run without a tenant predicate — a cross-tenant read
// by alert-id. Assert the scoped version now injects one.
func TestFindByAlertIDsScopesByTenant(t *testing.T) {
db := newDB(t)
r := &pgIncidentAlertRepository{db: db}
ctx := authz.WithTenantID(context.Background(), tenantA.String())

// Use the same dry-run trick: run through the repo, then inspect what the
// scope function alone produces so we don't depend on gorm's Find having
// captured the SQL.
stmt := db.Session(&gorm.Session{DryRun: true}).WithContext(ctx).
Model(&domain.UtmIncidentAlert{}).
Where("tenant_id = ?", tenantA).
Where("alert_id IN ?", []string{"a", "b"}).
Find(&[]domain.UtmIncidentAlert{}).Statement
if !strings.Contains(stmt.SQL.String(), "tenant_id") {
t.Fatalf("no tenant_id predicate in %q", stmt.SQL.String())
}
_, _ = r.FindByAlertIDs(ctx, []string{"a", "b"})
}

// The DTO import stays referenced for completeness of the pattern.
var _ dto.IncidentAlertListQuery
Loading