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
4 changes: 3 additions & 1 deletion backend/database/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"fmt"

adaudit_domain "github.com/utmstack/utmstack/backend/modules/adaudit/domain"
threatintel_domain "github.com/utmstack/utmstack/backend/modules/threatintel/domain"
alerts_domain "github.com/utmstack/utmstack/backend/modules/alerts/domain"
appconfig_domain "github.com/utmstack/utmstack/backend/modules/appconfig/domain"
audit_domain "github.com/utmstack/utmstack/backend/modules/audit/domain"
Expand All @@ -20,6 +19,7 @@ import (
arr_domain "github.com/utmstack/utmstack/backend/modules/soar/domain"
socai_domain "github.com/utmstack/utmstack/backend/modules/socai/domain"
tenant_domain "github.com/utmstack/utmstack/backend/modules/tenant/domain"
threatintel_domain "github.com/utmstack/utmstack/backend/modules/threatintel/domain"
"github.com/utmstack/utmstack/backend/pkg/joblease"
"gorm.io/gorm"

Expand Down Expand Up @@ -69,6 +69,8 @@ func Models() []any {
dashboards_domain.DashboardFilter{},
loganalyzer_domain.SavedQuery{},
adaudit_domain.ADUser{},
adaudit_domain.IdentityAlias{},
adaudit_domain.IdentityAliasAssignment{},
threatintel_domain.AnalystConversation{},
}
}
Expand Down
2 changes: 2 additions & 0 deletions backend/migrations/000004_adaudit_write.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DELETE FROM role_permission WHERE role_id = '00000000-0000-0000-0000-00000000a001' AND permission_name = 'adaudit.write';
DELETE FROM permissions WHERE name = 'adaudit.write';
11 changes: 11 additions & 0 deletions backend/migrations/000004_adaudit_write.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Identity aliases (operator-named, one link per account) need a write
-- permission. The init migration seeded adaudit.read only, and 000001 has
-- already run on every live install, so the new row lands here instead.
-- Idempotent on both fresh and upgraded databases.
INSERT INTO permissions (name, description) VALUES
('adaudit.write', 'Create and manage identity aliases in the audited user inventory')
ON CONFLICT (name) DO NOTHING;

INSERT INTO role_permission (role_id, permission_name)
SELECT '00000000-0000-0000-0000-00000000a001', 'adaudit.write'
ON CONFLICT DO NOTHING;
6 changes: 6 additions & 0 deletions backend/migrations/000005_identity_alias.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
DROP INDEX IF EXISTS idx_identity_alias_tenant_name;
ALTER TABLE identity_alias_assignment
DROP CONSTRAINT IF EXISTS fk_identity_alias_assignment_user,
DROP CONSTRAINT IF EXISTS fk_identity_alias_assignment_alias;
DROP TABLE IF EXISTS identity_alias_assignment;
DROP TABLE IF EXISTS identity_alias;
15 changes: 15 additions & 0 deletions backend/migrations/000005_identity_alias.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- The identity alias tables are created by GORM AutoMigrate (see
-- database.Models), which cannot express two things this feature needs:
-- * names are unique case-insensitively per tenant -> functional index,
-- * assignments cascade from both ends -> FK constraints.
-- Idempotent on both fresh and upgraded databases.
CREATE UNIQUE INDEX IF NOT EXISTS idx_identity_alias_tenant_name
ON identity_alias (tenant_id, lower(name));

ALTER TABLE identity_alias_assignment
DROP CONSTRAINT IF EXISTS fk_identity_alias_assignment_alias,
DROP CONSTRAINT IF EXISTS fk_identity_alias_assignment_user,
ADD CONSTRAINT fk_identity_alias_assignment_alias
FOREIGN KEY (alias_id) REFERENCES identity_alias (id) ON DELETE CASCADE,
ADD CONSTRAINT fk_identity_alias_assignment_user
FOREIGN KEY (user_id) REFERENCES ad_user (id) ON DELETE CASCADE;
10 changes: 10 additions & 0 deletions backend/migrations/000006_adaudit_write_for_user_writers.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- Reverse the 000006 grant. Cannot reconstruct who had users.write at
-- migration time, so strip adaudit.write from every role that has it
-- alongside users.write now.
DELETE FROM role_permission rp
WHERE rp.permission_name = 'adaudit.write'
AND EXISTS (
SELECT 1 FROM role_permission u
WHERE u.role_id = rp.role_id
AND u.permission_name = 'users.write'
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- Users who can write users (users.write) should also be able to write
-- identity aliases (adaudit.write) — same trust level. Grants the permission
-- to every role that holds users.write, so the effect is on all users
-- assigned those roles at migration time. Idempotent.
INSERT INTO role_permission (role_id, permission_name)
SELECT r.role_id, 'adaudit.write'
FROM role_permission r
WHERE r.permission_name = 'users.write'
ON CONFLICT DO NOTHING;
25 changes: 23 additions & 2 deletions backend/modules/adaudit/connectors/connectors.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,44 @@ package connectors
import (
"context"

"github.com/google/uuid"

"github.com/utmstack/utmstack/backend/modules/adaudit/domain"
"github.com/utmstack/utmstack/backend/modules/adaudit/dto"
"github.com/utmstack/utmstack/backend/pkg/database"
)

type ADUserRepository interface {
Upsert(ctx context.Context, users []domain.ADUser) error
List(ctx context.Context, f dto.ADUserFilter) ([]domain.ADUser, int64, error)
List(ctx context.Context, f dto.ADUserFilter) ([]dto.UserRow, int64, error)
Each(ctx context.Context, source string, fn func(domain.ADUser) error) error
Stats(ctx context.Context) (*dto.ADUserStats, error)
ResolveLinuxIdentity(ctx context.Context, tenantID, hostname, machineID string) (int64, error)
}

type ADUserUsecase interface {
Ingest(ctx context.Context, req dto.IngestRequest) (int, error)
List(ctx context.Context, f dto.ADUserFilter) (*database.List[domain.ADUser], error)
List(ctx context.Context, f dto.ADUserFilter) (*database.List[dto.UserRow], error)
Each(ctx context.Context, source string, fn func(domain.ADUser) error) error
Stats(ctx context.Context) (*dto.ADUserStats, error)
ResolveLinuxIdentity(ctx context.Context, req dto.ResolveLinuxIdentityRequest) (int64, error)
}

type IdentityRepository interface {
Create(ctx context.Context, name, actor string) (*domain.IdentityAlias, error)
List(ctx context.Context, search string) ([]dto.IdentityListItem, error)
Get(ctx context.Context, id uuid.UUID) (*domain.IdentityAlias, error)
Users(ctx context.Context, id uuid.UUID) ([]dto.UserRow, error)
Delete(ctx context.Context, id uuid.UUID) (int, error)
Assign(ctx context.Context, userID, aliasID uuid.UUID, actor string) error
Unassign(ctx context.Context, userID uuid.UUID, actor string) error
}

type IdentityUsecase interface {
Create(ctx context.Context, name, actor string) (*dto.IdentityResponse, error)
List(ctx context.Context, search string) ([]dto.IdentityListItem, error)
Detail(ctx context.Context, id uuid.UUID) (*dto.IdentityDetail, error)
Delete(ctx context.Context, id uuid.UUID) (int, error)
Assign(ctx context.Context, userID, aliasID uuid.UUID, actor string) error
Unassign(ctx context.Context, userID uuid.UUID, actor string) error
}
34 changes: 34 additions & 0 deletions backend/modules/adaudit/domain/identity_alias.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package domain

import (
"strings"
"time"

"github.com/google/uuid"
)

type IdentityAlias struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
TenantID uuid.UUID `gorm:"column:tenant_id;size:36;not null;index:idx_identity_alias_tenant,priority:1" json:"tenantId"`
Name string `gorm:"column:name;size:64;not null" json:"name"`
CreatedBy string `gorm:"column:created_by;size:150;not null" json:"createdBy"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"createdAt"`
Assignments []IdentityAliasAssignment `gorm:"foreignKey:AliasID;references:ID;constraint:false" json:"-"`
}

func (IdentityAlias) TableName() string { return "identity_alias" }

func ValidIdentityName(name string) bool {
trimmed := strings.TrimSpace(name)
runess := []rune(trimmed)
return len(runess) >= 2 && len(runess) <= 64
}

type IdentityAliasAssignment struct {
AliasID uuid.UUID `gorm:"column:alias_id;type:uuid;primaryKey" json:"aliasId"`
UserID uuid.UUID `gorm:"column:user_id;type:uuid;primaryKey;uniqueIndex:idx_identity_alias_assignment_user" json:"userId"`
AssignedBy string `gorm:"column:assigned_by;size:150;not null" json:"assignedBy"`
AssignedAt time.Time `gorm:"column:assigned_at;not null;default:now()" json:"assignedAt"`
}

func (IdentityAliasAssignment) TableName() string { return "identity_alias_assignment" }
21 changes: 21 additions & 0 deletions backend/modules/adaudit/domain/identity_alias_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package domain

import "testing"

func TestValidIdentityName(t *testing.T) {
valid := []string{" ops ", "ops-team", "ab"}
invalid := []string{"", " ", "a ", "a", "b"}
for _, name := range valid {
if !ValidIdentityName(name) {
t.Errorf("ValidIdentityName(%q) = false, want true", name)
}
}
for _, name := range invalid {
if ValidIdentityName(name) {
t.Errorf("ValidIdentityName(%q) = true, want false", name)
}
}
if ValidIdentityName(string(make([]rune, 65))) {
t.Error("a 65-char name is too long")
}
}
31 changes: 17 additions & 14 deletions backend/modules/adaudit/dto/ad_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@ type IngestRequest struct {
}

type ADUserFilter struct {
Search string `form:"search"` // substring on samAccountName/sid
Source string `form:"source"` // "windows"|"linux"|"o365"|"azure"|"gcp"|"aws"|"" (all)
Active *bool `form:"active"`
Status string `form:"status"` // active|disabled|deleted|stale|service — overrides Active when set
Sort string `form:"sort"` // recent (last_seen desc) | name (default, samAccountName asc)
Search string `form:"search"` // substring on samAccountName/sid
Source string `form:"source"` // "windows"|"linux"|"o365"|"azure"|"gcp"|"aws"|"" (all)
Active *bool `form:"active"`
Status string `form:"status"` // active|disabled|deleted|stale|service — overrides Active when set
Sort string `form:"sort"` // recent (last_seen desc) | name (default, samAccountName asc)
Identity string `form:"identity"` // a UUID filters to users assigned to that identity; the literal "none" to users without one
database.Params
}

Expand All @@ -51,15 +52,17 @@ type SourceCount struct {
}

type ADUserStats struct {
Total int64 `json:"total"`
Active int64 `json:"active"`
Disabled int64 `json:"disabled"`
Deleted int64 `json:"deleted"`
Stale int64 `json:"stale"`
Service int64 `json:"service"`
Seen24h int64 `json:"seen_24h"`
BySource SourceCount `json:"by_source"`
ByDomain []DomainCount `json:"by_domain"`
Total int64 `json:"total"`
Active int64 `json:"active"`
Disabled int64 `json:"disabled"`
Deleted int64 `json:"deleted"`
Stale int64 `json:"stale"`
Service int64 `json:"service"`
Seen24h int64 `json:"seen_24h"`
Identities int64 `json:"identities"`
Unassigned int64 `json:"unassigned"`
BySource SourceCount `json:"by_source"`
ByDomain []DomainCount `json:"by_domain"`
}

type ResolveLinuxIdentityRequest struct {
Expand Down
40 changes: 40 additions & 0 deletions backend/modules/adaudit/dto/identity_alias.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package dto

import (
"time"

"github.com/utmstack/utmstack/backend/modules/adaudit/domain"
)

type CreateIdentityRequest struct {
Name string `json:"name"`
}

type IdentityResponse struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"`
}

type IdentityListItem struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"`
Count int `json:"count"`
}

type IdentityDetail struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"`
Users []UserRow `json:"users"`
}

type UserRow struct {
domain.ADUser
Identity *string `json:"identity"`
}

type AssignIdentityRequest struct {
IdentityID string `json:"identityId"`
}
10 changes: 9 additions & 1 deletion backend/modules/adaudit/handler/ad_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/utmstack/utmstack/backend/modules/adaudit/domain"

"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/threatwinds/go-sdk/catcher"
"github.com/utmstack/utmstack/backend/modules/adaudit/connectors"
"github.com/utmstack/utmstack/backend/modules/adaudit/dto"
Expand Down Expand Up @@ -58,10 +59,11 @@ func (h *ADUserHandler) Ingest(c *gin.Context) {
// @Param source query string false "Filter by source: windows | linux (omit for all)"
// @Param active query bool false "Filter by active"
// @Param status query string false "Lifecycle bucket: active|disabled|deleted|stale|service (overrides active)"
// @Param identity query string false "Filter by identity alias uuid, or the literal 'none' for users without one"
// @Param sort query string false "Sort: recent (last seen) or name (default)"
// @Param page query int false "Page (0-based)"
// @Param size query int false "Page size"
// @Success 200 {array} domain.ADUser
// @Success 200 {array} dto.UserRow
// @Header 200 {string} X-Total-Count "Total records"
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
Expand All @@ -76,6 +78,12 @@ func (h *ADUserHandler) List(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "source must be 'windows', 'linux', a cloud source, or omitted"})
return
}
if f.Identity != "" && f.Identity != "none" {
if _, err := uuid.Parse(f.Identity); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "identity must be a uuid or the literal 'none'"})
return
}
}
res, err := h.uc.List(c.Request.Context(), f)
if err != nil {
_ = catcher.Error("adaudit: list failed", err, nil)
Expand Down
Loading
Loading