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
19 changes: 19 additions & 0 deletions backend/migrations/000002_linux_user_auditor.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- 000002_linux_user_auditor.down.sql
--
-- Reverse the partial-index topology and restore the full-table unique index.
-- The additive columns (source, machine_id, uid_number, hostname, username) are
-- intentionally NOT dropped — leaving them in place is harmless for the old code
-- path and preserves data. If a full schema rollback is required, drop the
-- columns manually after this migration runs.
--
-- Ordering caveat (documented in the runbook):
-- Rolling back the backend code without running this .down.sql leaves the old
-- Windows ON CONFLICT (tenant_id, sid) statement targeting an index that no
-- longer exists. The Windows upsert path will fail. ALWAYS run this .down.sql
-- BEFORE rolling back the code.

DROP INDEX IF EXISTS idx_aduser_linux;
DROP INDEX IF EXISTS idx_aduser_windows;

CREATE UNIQUE INDEX IF NOT EXISTS idx_ad_user_tenant_sid
ON ad_user (tenant_id, sid);
44 changes: 44 additions & 0 deletions backend/migrations/000002_linux_user_auditor.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
-- 000002_linux_user_auditor.up.sql
--
-- Purpose:
-- Extend the ad_user table so it can host both Windows (AD) users and Linux users
-- in a single inventory. Windows uniqueness stays on (tenant_id, sid); Linux
-- uniqueness moves to (tenant_id, machine_id, uid_number). Both are enforced by
-- Postgres partial unique indexes — the two universes cannot collide with each
-- other, and provisional Linux rows (machine_id IS NULL) coexist because Postgres
-- treats NULLs as distinct in unique indexes.
--
-- Ordering:
-- AutoMigrate (GORM) runs BEFORE this migration and adds the columns declared on
-- domain.ADUser (source, machine_id, uid_number, hostname, username) with their
-- defaults. This SQL file swaps the old full-table unique index for the two
-- source-discriminated partial indexes.
--
-- Safety:
-- ADD COLUMN IF NOT EXISTS is a belt-and-suspenders guard against environments
-- where AutoMigrate ran partially or was skipped.
--

-- Safety net: ensure the source column exists with the correct default before the
-- partial indexes reference it. AutoMigrate should have already added it, but this
-- is idempotent and covers partial-migration recovery paths.
ALTER TABLE ad_user
ADD COLUMN IF NOT EXISTS source VARCHAR(16) NOT NULL DEFAULT 'windows';

-- Drop the old full-table unique index. Windows uniqueness moves to a partial
-- variant that only enforces on rows where source = 'windows'.
DROP INDEX IF EXISTS idx_ad_user_tenant_sid;

-- Windows partial unique index.
CREATE UNIQUE INDEX IF NOT EXISTS idx_aduser_windows
ON ad_user (tenant_id, sid)
WHERE source = 'windows';

-- Linux partial unique index. Rows where machine_id IS NULL are provisional and
-- Postgres treats their NULL as distinct — so multiple provisional rows for the
-- same (tenant_id, hostname, username) with machine_id = NULL are permitted at the
-- index level. Application-level de-duplication (see repository.Upsert) prevents
-- them in practice.
CREATE UNIQUE INDEX IF NOT EXISTS idx_aduser_linux
ON ad_user (tenant_id, machine_id, uid_number)
WHERE source = 'linux';
6 changes: 4 additions & 2 deletions backend/modules/adaudit/connectors/connectors.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import (
type ADUserRepository interface {
Upsert(ctx context.Context, users []domain.ADUser) error
List(ctx context.Context, f dto.ADUserFilter) ([]domain.ADUser, int64, error)
All(ctx context.Context) ([]domain.ADUser, error)
All(ctx context.Context, source string) ([]domain.ADUser, error)
Stats(ctx context.Context, tenantID string) (*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)
All(ctx context.Context) ([]domain.ADUser, error)
All(ctx context.Context, source string) ([]domain.ADUser, error)
Stats(ctx context.Context, tenantID string) (*dto.ADUserStats, error)
ResolveLinuxIdentity(ctx context.Context, req dto.ResolveLinuxIdentityRequest) (int64, error)
}
13 changes: 9 additions & 4 deletions backend/modules/adaudit/domain/ad_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ import "time"

type ADUser struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
TenantID string `gorm:"column:tenant_id;size:64;not null;uniqueIndex:idx_ad_user_tenant_sid" json:"tenantId"`
SID string `gorm:"column:sid;size:128;not null;uniqueIndex:idx_ad_user_tenant_sid" json:"sid"`
SamAccountName string `gorm:"column:sam_account_name;size:255" json:"samAccountName"`
Domain string `gorm:"column:domain;size:255" json:"domain"`
TenantID string `gorm:"column:tenant_id;size:64;not null" json:"tenantId"`
Source string `gorm:"column:source;size:16;not null;default:'windows'" json:"source"`
SID *string `gorm:"column:sid;size:128" json:"sid,omitempty"`
SamAccountName string `gorm:"column:sam_account_name;size:255" json:"samAccountName,omitempty"`
Domain string `gorm:"column:domain;size:255" json:"domain,omitempty"`
MachineID *string `gorm:"column:machine_id;size:64" json:"machineId,omitempty"`
UIDNumber *string `gorm:"column:uid_number;size:32" json:"uidNumber,omitempty"`
Hostname *string `gorm:"column:hostname;size:255" json:"hostname,omitempty"`
Username *string `gorm:"column:username;size:255" json:"username,omitempty"`
Active bool `gorm:"column:active;not null;default:true" json:"active"`
AccountCreatedAt *time.Time `gorm:"column:account_created_at" json:"accountCreatedAt,omitempty"`
LastLogon *time.Time `gorm:"column:last_logon" json:"lastLogon,omitempty"`
Expand Down
30 changes: 27 additions & 3 deletions backend/modules/adaudit/dto/ad_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@ import (

type IngestUser struct {
TenantID string `json:"tenantId"`
SID string `json:"sid" binding:"required"`
SamAccountName string `json:"samAccountName"`
Domain string `json:"domain"`
Source string `json:"source,omitempty"` // "windows"|"linux"; defaults to "windows" if absent
SID string `json:"sid,omitempty"` // required when source=windows; enforced in usecase
SamAccountName string `json:"samAccountName,omitempty"`
Domain string `json:"domain,omitempty"`
MachineID *string `json:"machineId,omitempty"`
UIDNumber *string `json:"uidNumber,omitempty"`
Hostname *string `json:"hostname,omitempty"`
Username *string `json:"username,omitempty"`
Active *bool `json:"active"`
AccountCreatedAt *time.Time `json:"accountCreatedAt"`
LastLogon *time.Time `json:"lastLogon"`
Expand All @@ -25,6 +30,7 @@ type IngestRequest struct {
type ADUserFilter struct {
Search string `form:"search"` // substring on samAccountName/sid
TenantID string `form:"tenantId"` // exact
Source string `form:"source"` // "windows"|"linux"|"" (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)
Expand All @@ -41,6 +47,12 @@ type DomainCount struct {
Count int64 `json:"count"`
}

// SourceCount is the by-source breakdown returned by GET /ad-audit/stats.
type SourceCount struct {
Windows int64 `json:"windows"`
Linux int64 `json:"linux"`
}

// ADUserStats is the inventory roll-up the UI overview renders. Counts honor the
// optional tenant scope; Tenants is always the global distinct list so the
// tenant picker stays stable regardless of the active scope.
Expand All @@ -52,6 +64,18 @@ type ADUserStats struct {
Stale int64 `json:"stale"`
Service int64 `json:"service"`
Seen24h int64 `json:"seen_24h"`
BySource SourceCount `json:"by_source"`
ByDomain []DomainCount `json:"by_domain"`
Tenants []string `json:"tenants"`
}

// ResolveLinuxIdentityRequest is the payload the ad-audit plugin sends when it
// learns the machine-id for a host that already has provisional Linux user rows.
// The backend updates all matching provisional rows to set machine_id: if a
// resolved row already exists for (tenant_id, machine_id, uid_number),
// the provisional row is left untouched.
type ResolveLinuxIdentityRequest struct {
TenantID string `json:"tenant_id" binding:"required"`
Hostname string `json:"hostname" binding:"required"`
MachineID string `json:"machine_id" binding:"required"`
}
51 changes: 47 additions & 4 deletions backend/modules/adaudit/handler/ad_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ func (h *ADUserHandler) Ingest(c *gin.Context) {
// @Tags AD Audit
// @Security BearerAuth
// @Produce json
// @Param search query string false "Substring on samAccountName/sid"
// @Param search query string false "Substring on samAccountName/sid/username"
// @Param source query string false "Filter by source: windows | linux (omit for all)"
// @Param tenantId query string false "Filter by tenant"
// @Param active query bool false "Filter by active"
// @Param status query string false "Lifecycle bucket: active|disabled|deleted|stale|service (overrides active)"
Expand All @@ -60,6 +61,7 @@ func (h *ADUserHandler) Ingest(c *gin.Context) {
// @Param size query int false "Page size"
// @Success 200 {array} domain.ADUser
// @Header 200 {string} X-Total-Count "Total records"
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /ad-audit/users [get]
func (h *ADUserHandler) List(c *gin.Context) {
Expand All @@ -68,6 +70,10 @@ func (h *ADUserHandler) List(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if f.Source != "" && f.Source != "windows" && f.Source != "linux" {
c.JSON(http.StatusBadRequest, gin.H{"error": "source must be 'windows', 'linux', or omitted"})
return
}
res, err := h.uc.List(c.Request.Context(), f)
if err != nil {
_ = catcher.Error("adaudit: list failed", err, nil)
Expand Down Expand Up @@ -108,17 +114,54 @@ func (h *ADUserHandler) Stats(c *gin.Context) {
//
// @Summary Export all AD users (internal)
// @Description Internal endpoint the ad-audit plugin calls at startup to seed its in-memory cache.
// @Description Accepts an optional `source` filter so the plugin can seed its Windows and Linux
// @Tags AD Audit
// @Produce json
// @Success 200 {array} domain.ADUser
// @Failure 500 {object} map[string]string
// @Param source query string false "Filter by source: windows | linux (omit for all)"
// @Success 200 {array} domain.ADUser
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /ad-audit/users/sync [get]
func (h *ADUserHandler) Sync(c *gin.Context) {
users, err := h.uc.All(c.Request.Context())
source := c.Query("source")
if source != "" && source != "windows" && source != "linux" {
c.JSON(http.StatusBadRequest, gin.H{"error": "source must be 'windows', 'linux', or omitted"})
return
}
users, err := h.uc.All(c.Request.Context(), source)
if err != nil {
_ = catcher.Error("adaudit: sync failed", err, nil)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not export users"})
return
}
c.JSON(http.StatusOK, users)
}

// Resolve godoc
//
// @Summary Resolve provisional Linux user rows (internal)
// @Description Internal endpoint the ad-audit plugin calls once it learns the
// @Description machine-id for a host that already has provisional Linux user rows
// @Description (machine_id IS NULL).
// @Tags AD Audit
// @Accept json
// @Produce json
// @Param input body dto.ResolveLinuxIdentityRequest true "Resolution payload"
// @Success 200 {object} map[string]int64
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /ad-audit/users/resolve [post]
func (h *ADUserHandler) Resolve(c *gin.Context) {
var req dto.ResolveLinuxIdentityRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
n, err := h.uc.ResolveLinuxIdentity(c.Request.Context(), req)
if err != nil {
_ = catcher.Error("adaudit: resolve failed", err, nil)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not resolve provisional rows"})
return
}
c.JSON(http.StatusOK, gin.H{"resolved": n})
}
Loading
Loading