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
30 changes: 26 additions & 4 deletions backend/modules.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ package main

import (
"context"
"os"
"path/filepath"
"strings"
"time"

"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/google/uuid"
iam_handler "github.com/utmstack/utmstack/backend/modules/iam/handler"
"github.com/utmstack/utmstack/backend/pkg/joblease"
"path/filepath"
"strings"
"time"

dash_usecase "github.com/utmstack/utmstack/backend/modules/dashboards/usecase"
"github.com/utmstack/utmstack/backend/pkg/eventstore"
Expand Down Expand Up @@ -43,8 +45,10 @@ import (
socai_repository "github.com/utmstack/utmstack/backend/modules/socai/repository"
"github.com/utmstack/utmstack/backend/modules/storage"
"github.com/utmstack/utmstack/backend/modules/tenant"
tenant_connectors "github.com/utmstack/utmstack/backend/modules/tenant/connectors"
tenant_domain "github.com/utmstack/utmstack/backend/modules/tenant/domain"
tenant_dto "github.com/utmstack/utmstack/backend/modules/tenant/dto"
ep_repository "github.com/utmstack/utmstack/backend/modules/eventprocessing/repository"
"github.com/utmstack/utmstack/backend/modules/threatintel"
"github.com/utmstack/utmstack/backend/pkg/agentmanager"
"github.com/utmstack/utmstack/backend/pkg/env"
Expand Down Expand Up @@ -214,7 +218,25 @@ func initModules(db *gorm.DB, cfg *config) *modules {
env.Int("NOTIFICATIONS_RETENTION_DAYS", 365, false))

iam_handler.AppBaseURL = env.String("APP_BASE_URL", "", false)
tenantMod = tenant.NewModule(db, userUsecase)

// Extra purgers: ClickHouse rows and per-tenant filesystem folders.
// Each subsystem contributes one; failures short-circuit before the SQL purge
// so the tenant row survives an outage.
var extraPurgers []tenant_connectors.TenantPurgeFunc
if events != nil {
extraPurgers = append(extraPurgers, func(ctx context.Context, id uuid.UUID) error {
return events.PurgeTenant(ctx, id.String())
})
}
rulesUserDir := filepath.Join(env.String(ep_repository.RulesDirEnv, ep_repository.DefaultRulesDir, false), ep_repository.UserSubdir)
pipelinesUserDir := filepath.Join(env.String(ep_repository.PipelinesDirEnv, ep_repository.DefaultPipelinesDir, false), ep_repository.UserSubdir)
extraPurgers = append(extraPurgers,
func(_ context.Context, id uuid.UUID) error { return os.RemoveAll(filepath.Join(rulesUserDir, id.String())) },
func(_ context.Context, id uuid.UUID) error {
return os.RemoveAll(filepath.Join(pipelinesUserDir, id.String()))
},
)
tenantMod = tenant.NewModule(db, userUsecase, extraPurgers...)
tenantListerForConfig = tenantLister
iamMod := iam.NewModule(authUsecase, userUsecase, roleUsecase, tfaUsecase, apiKeyUsecase, idpUsecase, federationUsecase, cfg.uploadDir, tenantLister)
iamMod.SetSessionPurger(iam_usecase.NewSessionPurger(refreshRepo, joblease.New(db)))
Expand Down
1 change: 1 addition & 0 deletions backend/modules/tenant/connectors/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ type TenantRepository interface {
FindByID(ctx context.Context, id uuid.UUID) (*domain.Tenant, error)
FindByDomain(ctx context.Context, domain string) (*domain.Tenant, error)
List(ctx context.Context, f dto.Filter) ([]domain.Tenant, int64, error)
PurgeAllTenantData(ctx context.Context, id uuid.UUID) error
}
9 changes: 9 additions & 0 deletions backend/modules/tenant/connectors/usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ import (
"github.com/utmstack/utmstack/backend/modules/tenant/dto"
)

// TenantPurgeFunc is a purger contributed by a subsystem that owns
// tenant-scoped data outside PostgreSQL (ClickHouse tables, on-disk config
// directories, etc). Called during PermanentlyDelete before the SQL purge so
// that a failure leaves the tenant row intact and the whole operation stays
// retryable.
type TenantPurgeFunc func(ctx context.Context, id uuid.UUID) error

// UserProvisioner is iam's create, nothing more. Tenant owns tenancy, so it is
// this module that puts the tenant on the context before calling; iam only makes
// the account it is asked for, wherever the caller says it belongs.
Expand All @@ -28,5 +35,7 @@ type TenantUsecase interface {
List(ctx context.Context, f dto.Filter) ([]domain.Tenant, int64, error)
SetSupportAccess(ctx context.Context, id uuid.UUID, level domain.SupportAccess) (*domain.Tenant, error)
Terminate(ctx context.Context, id uuid.UUID) error
Reactivate(ctx context.Context, id uuid.UUID) (*domain.Tenant, error)
PermanentlyDelete(ctx context.Context, id uuid.UUID) error
ResolveDomain(ctx context.Context, host string) (*domain.Tenant, error)
}
1 change: 1 addition & 0 deletions backend/modules/tenant/domain/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ var (
ErrLimitInvalid = errors.New("a limit must be a whole number, or null to remove it")
ErrLimitExceedsLicense = errors.New("the limits handed out to tenants would exceed what this instance is licensed for")
ErrDefaultTenant = errors.New("the default tenant holds the platform plane and cannot be changed this way")
ErrNotTerminated = errors.New("tenant is not terminated")
)
64 changes: 63 additions & 1 deletion backend/modules/tenant/handler/tenant.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,67 @@ func (h *TenantHandler) Terminate(c *gin.Context) {
c.Status(http.StatusNoContent)
}

// Reactivate godoc
//
// @Summary Reactivate a terminated tenant
// @Description Flips a TERMINATED tenant back to ACTIVE. Fails if the tenant is not terminated.
// @Tags Tenants
// @Security BearerAuth
// @Produce json
// @Param id path string true "Tenant id"
// @Success 200 {object} domain.Tenant
// @Failure 400 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /tenants/{id}/reactivate [post]
func (h *TenantHandler) Reactivate(c *gin.Context) {
tid, ok := pathTenantID(c)
if !ok {
return
}
t, err := h.uc.Reactivate(c.Request.Context(), tid)
audit.Record(c, audit_connectors.Event{
Action: "tenant.reactivate",
ResourceType: "tenant",
ResourceID: c.Param("id"),
}, audit_domain.TENANT_REACTIVATE_ATTEMPT, audit_domain.TENANT_REACTIVATE_SUCCESS, err)
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusOK, t)
}

// PermanentlyDelete godoc
//
// @Summary Permanently delete a terminated tenant
// @Description Hard-deletes the tenant row and all rows scoped by tenant_id across the schema. Only allowed when the tenant is TERMINATED.
// @Tags Tenants
// @Security BearerAuth
// @Param id path string true "Tenant id"
// @Success 204
// @Failure 400 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /tenants/{id}/permanent [delete]
func (h *TenantHandler) PermanentlyDelete(c *gin.Context) {
tid, ok := pathTenantID(c)
if !ok {
return
}
err := h.uc.PermanentlyDelete(c.Request.Context(), tid)
audit.Record(c, audit_connectors.Event{
Action: "tenant.purge",
ResourceType: "tenant",
ResourceID: c.Param("id"),
}, audit_domain.TENANT_PURGE_ATTEMPT, audit_domain.TENANT_PURGE_SUCCESS, err)
if err != nil {
writeError(c, err)
return
}
c.Status(http.StatusNoContent)
}

func writeError(c *gin.Context, err error) {
switch {
case errors.Is(err, domain.ErrNotFound):
Expand All @@ -192,7 +253,8 @@ func writeError(c *gin.Context, err error) {
case errors.Is(err, domain.ErrNameRequired), errors.Is(err, domain.ErrDomainRequired),
errors.Is(err, domain.ErrDomainInvalid), errors.Is(err, domain.ErrStatusInvalid),
errors.Is(err, domain.ErrAlreadyTerminated), errors.Is(err, domain.ErrSupportInvalid),
errors.Is(err, domain.ErrLimitNegative), errors.Is(err, domain.ErrLimitInvalid):
errors.Is(err, domain.ErrLimitNegative), errors.Is(err, domain.ErrLimitInvalid),
errors.Is(err, domain.ErrNotTerminated):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
Expand Down
4 changes: 2 additions & 2 deletions backend/modules/tenant/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ type Module struct {
bootstrapUC connectors.BootstrapUsecase
}

func NewModule(db *gorm.DB, admin connectors.UserProvisioner) *Module {
func NewModule(db *gorm.DB, admin connectors.UserProvisioner, extras ...connectors.TenantPurgeFunc) *Module {
repo := repository.NewTenantRepository(db)
tenantUC := usecase.NewTenantUsecase(repo, admin)
tenantUC := usecase.NewTenantUsecase(repo, admin, extras)

return &Module{
tenantHandler: handler.NewTenantHandler(tenantUC),
Expand Down
23 changes: 23 additions & 0 deletions backend/modules/tenant/repository/tenant_pg.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package repository
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"

"gorm.io/gorm"
Expand Down Expand Up @@ -50,6 +51,28 @@ func (r *pgTenantRepository) findOne(ctx context.Context, query string, arg any)
return &t, nil
}

func (r *pgTenantRepository) PurgeAllTenantData(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var tables []string
if err := tx.Raw(`
SELECT table_name
FROM information_schema.columns
WHERE table_schema = current_schema()
AND column_name = 'tenant_id'
AND table_name <> 'tenant'
`).Scan(&tables).Error; err != nil {
return err
}
for _, tbl := range tables {
sql := fmt.Sprintf(`DELETE FROM %q WHERE tenant_id::text = ?`, tbl)
if err := tx.Exec(sql, id.String()).Error; err != nil {
return err
}
}
return nil
})
}

func (r *pgTenantRepository) List(ctx context.Context, f dto.Filter) ([]domain.Tenant, int64, error) {
q := r.db.WithContext(ctx).Model(&domain.Tenant{})

Expand Down
2 changes: 2 additions & 0 deletions backend/modules/tenant/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth, mssp, platform gi
g.POST("", write, h.Create)
g.PUT("/:id", write, h.Update)
g.DELETE("/:id", write, h.Terminate)
g.POST("/:id/reactivate", write, h.Reactivate)
g.DELETE("/:id/permanent", write, h.PermanentlyDelete)

own := api.Group("/tenants", userAuth, mssp)
ownTenant := []gin.HandlerFunc{middleware.RequireAdmin(), middleware.RequireOwnTenant("id")}
Expand Down
49 changes: 45 additions & 4 deletions backend/modules/tenant/usecase/tenant.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ import (
const defaultPageSize = 25

type tenantUsecase struct {
repo connectors.TenantRepository
admin connectors.UserProvisioner
repo connectors.TenantRepository
admin connectors.UserProvisioner
extras []connectors.TenantPurgeFunc
}

func NewTenantUsecase(repo connectors.TenantRepository, admin connectors.UserProvisioner) connectors.TenantUsecase {
return &tenantUsecase{repo: repo, admin: admin}
func NewTenantUsecase(repo connectors.TenantRepository, admin connectors.UserProvisioner, extras []connectors.TenantPurgeFunc) connectors.TenantUsecase {
return &tenantUsecase{repo: repo, admin: admin, extras: extras}
}

func (u *tenantUsecase) Create(ctx context.Context, req dto.CreateRequest) (*domain.Tenant, error) {
Expand Down Expand Up @@ -185,6 +186,46 @@ func (u *tenantUsecase) Terminate(ctx context.Context, id uuid.UUID) error {
return u.repo.Update(ctx, t)
}

func (u *tenantUsecase) Reactivate(ctx context.Context, id uuid.UUID) (*domain.Tenant, error) {
if id.String() == authz.DefaultTenantID {
return nil, domain.ErrDefaultTenant
}
t, err := u.GetByID(ctx, id)
if err != nil {
return nil, err
}
if t.Status != domain.StatusTerminated {
return nil, domain.ErrNotTerminated
}
t.Status = domain.StatusActive
if err := u.repo.Update(ctx, t); err != nil {
return nil, err
}
return t, nil
}

func (u *tenantUsecase) PermanentlyDelete(ctx context.Context, id uuid.UUID) error {
if id.String() == authz.DefaultTenantID {
return domain.ErrDefaultTenant
}
t, err := u.GetByID(ctx, id)
if err != nil {
return err
}
if t.Status != domain.StatusTerminated {
return domain.ErrNotTerminated
}
for _, purge := range u.extras {
if err := purge(ctx, id); err != nil {
return fmt.Errorf("external purge: %w", err)
}
}
if err := u.repo.PurgeAllTenantData(ctx, id); err != nil {
return err
}
return u.repo.Delete(ctx, id)
}

func (u *tenantUsecase) ResolveDomain(ctx context.Context, host string) (*domain.Tenant, error) {
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
Expand Down
86 changes: 86 additions & 0 deletions frontend/src/features/tenants/components/CreateTenantDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Building2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/shared/components/ui/button'
import { Input } from '@/shared/components/ui/input'
import { tenantsHttpService } from '../services/tenants-http.service'
import type { CreateTenantRequest } from '../types/tenant.types'
import { Modal } from './Modal'
import { Field } from './Field'
import { tenantError } from './tenant-error'

export function CreateTenantDialog({
onClose,
onCreated,
}: {
onClose: () => void
onCreated: () => void
}) {
const { t } = useTranslation()
const [name, setName] = useState('')
const [domain, setDomain] = useState('')
const [adminEmail, setAdminEmail] = useState('')
const [busy, setBusy] = useState(false)

const valid =
name.trim().length >= 2 && domain.trim().length >= 3 && /.+@.+\..+/.test(adminEmail.trim())

const submit = async () => {
if (!valid || busy) return
setBusy(true)
try {
const body: CreateTenantRequest = {
name: name.trim(),
domain: domain.trim().toLowerCase(),
adminEmail: adminEmail.trim(),
}
await tenantsHttpService.create(body)
toast.success(t('tenants.toast.created'))
onCreated()
} catch (err) {
toast.error(tenantError(err, t))
} finally {
setBusy(false)
}
}

return (
<Modal
title={t('tenants.create.title')}
subtitle={t('tenants.create.subtitle')}
icon={Building2}
onClose={onClose}
footer={
<>
<Button variant="outline" size="sm" onClick={onClose} disabled={busy}>
{t('tenants.cancel')}
</Button>
<Button size="sm" disabled={!valid || busy} onClick={() => void submit()}>
{busy ? t('tenants.saving') : t('tenants.create.submit')}
</Button>
</>
}
>
<Field label={t('tenants.fields.name')}>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Acme Corp" />
</Field>
<Field label={t('tenants.fields.domain')} hint={t('tenants.fields.domainHint')}>
<Input
value={domain}
onChange={(e) => setDomain(e.target.value)}
className="font-mono"
placeholder="acme.utmstack.com"
/>
</Field>
<Field label={t('tenants.fields.adminEmail')} hint={t('tenants.fields.adminEmailHint')}>
<Input
type="email"
value={adminEmail}
onChange={(e) => setAdminEmail(e.target.value)}
placeholder="admin@acme.com"
/>
</Field>
</Modal>
)
}
Loading
Loading