Skip to content

Commit ebb76f7

Browse files
Backlog/v12 terminated tenants (#2481)
* feat[backend](tenants): added purge tenant data endpoints and update after termination operation * feat[backend](tenants): added purge clickhouse data and local filesystem purge on tenants purge request * fix[frontend](tenants): added reactivate and purge all on terminated tenants * fix[frontend](tenants): added missing translations
1 parent cd136a1 commit ebb76f7

27 files changed

Lines changed: 1126 additions & 667 deletions

‎backend/modules.go‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ package main
22

33
import (
44
"context"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"time"
9+
510
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
611
"github.com/google/uuid"
712
iam_handler "github.com/utmstack/utmstack/backend/modules/iam/handler"
813
"github.com/utmstack/utmstack/backend/pkg/joblease"
9-
"path/filepath"
10-
"strings"
11-
"time"
1214

1315
dash_usecase "github.com/utmstack/utmstack/backend/modules/dashboards/usecase"
1416
"github.com/utmstack/utmstack/backend/pkg/eventstore"
@@ -43,8 +45,10 @@ import (
4345
socai_repository "github.com/utmstack/utmstack/backend/modules/socai/repository"
4446
"github.com/utmstack/utmstack/backend/modules/storage"
4547
"github.com/utmstack/utmstack/backend/modules/tenant"
48+
tenant_connectors "github.com/utmstack/utmstack/backend/modules/tenant/connectors"
4649
tenant_domain "github.com/utmstack/utmstack/backend/modules/tenant/domain"
4750
tenant_dto "github.com/utmstack/utmstack/backend/modules/tenant/dto"
51+
ep_repository "github.com/utmstack/utmstack/backend/modules/eventprocessing/repository"
4852
"github.com/utmstack/utmstack/backend/modules/threatintel"
4953
"github.com/utmstack/utmstack/backend/pkg/agentmanager"
5054
"github.com/utmstack/utmstack/backend/pkg/env"
@@ -214,7 +218,25 @@ func initModules(db *gorm.DB, cfg *config) *modules {
214218
env.Int("NOTIFICATIONS_RETENTION_DAYS", 365, false))
215219

216220
iam_handler.AppBaseURL = env.String("APP_BASE_URL", "", false)
217-
tenantMod = tenant.NewModule(db, userUsecase)
221+
222+
// Extra purgers: ClickHouse rows and per-tenant filesystem folders.
223+
// Each subsystem contributes one; failures short-circuit before the SQL purge
224+
// so the tenant row survives an outage.
225+
var extraPurgers []tenant_connectors.TenantPurgeFunc
226+
if events != nil {
227+
extraPurgers = append(extraPurgers, func(ctx context.Context, id uuid.UUID) error {
228+
return events.PurgeTenant(ctx, id.String())
229+
})
230+
}
231+
rulesUserDir := filepath.Join(env.String(ep_repository.RulesDirEnv, ep_repository.DefaultRulesDir, false), ep_repository.UserSubdir)
232+
pipelinesUserDir := filepath.Join(env.String(ep_repository.PipelinesDirEnv, ep_repository.DefaultPipelinesDir, false), ep_repository.UserSubdir)
233+
extraPurgers = append(extraPurgers,
234+
func(_ context.Context, id uuid.UUID) error { return os.RemoveAll(filepath.Join(rulesUserDir, id.String())) },
235+
func(_ context.Context, id uuid.UUID) error {
236+
return os.RemoveAll(filepath.Join(pipelinesUserDir, id.String()))
237+
},
238+
)
239+
tenantMod = tenant.NewModule(db, userUsecase, extraPurgers...)
218240
tenantListerForConfig = tenantLister
219241
iamMod := iam.NewModule(authUsecase, userUsecase, roleUsecase, tfaUsecase, apiKeyUsecase, idpUsecase, federationUsecase, cfg.uploadDir, tenantLister)
220242
iamMod.SetSessionPurger(iam_usecase.NewSessionPurger(refreshRepo, joblease.New(db)))

‎backend/modules/tenant/connectors/repository.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ type TenantRepository interface {
1515
FindByID(ctx context.Context, id uuid.UUID) (*domain.Tenant, error)
1616
FindByDomain(ctx context.Context, domain string) (*domain.Tenant, error)
1717
List(ctx context.Context, f dto.Filter) ([]domain.Tenant, int64, error)
18+
PurgeAllTenantData(ctx context.Context, id uuid.UUID) error
1819
}

‎backend/modules/tenant/connectors/usecase.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ import (
1010
"github.com/utmstack/utmstack/backend/modules/tenant/dto"
1111
)
1212

13+
// TenantPurgeFunc is a purger contributed by a subsystem that owns
14+
// tenant-scoped data outside PostgreSQL (ClickHouse tables, on-disk config
15+
// directories, etc). Called during PermanentlyDelete before the SQL purge so
16+
// that a failure leaves the tenant row intact and the whole operation stays
17+
// retryable.
18+
type TenantPurgeFunc func(ctx context.Context, id uuid.UUID) error
19+
1320
// UserProvisioner is iam's create, nothing more. Tenant owns tenancy, so it is
1421
// this module that puts the tenant on the context before calling; iam only makes
1522
// the account it is asked for, wherever the caller says it belongs.
@@ -28,5 +35,7 @@ type TenantUsecase interface {
2835
List(ctx context.Context, f dto.Filter) ([]domain.Tenant, int64, error)
2936
SetSupportAccess(ctx context.Context, id uuid.UUID, level domain.SupportAccess) (*domain.Tenant, error)
3037
Terminate(ctx context.Context, id uuid.UUID) error
38+
Reactivate(ctx context.Context, id uuid.UUID) (*domain.Tenant, error)
39+
PermanentlyDelete(ctx context.Context, id uuid.UUID) error
3140
ResolveDomain(ctx context.Context, host string) (*domain.Tenant, error)
3241
}

‎backend/modules/tenant/domain/errors.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ var (
1616
ErrLimitInvalid = errors.New("a limit must be a whole number, or null to remove it")
1717
ErrLimitExceedsLicense = errors.New("the limits handed out to tenants would exceed what this instance is licensed for")
1818
ErrDefaultTenant = errors.New("the default tenant holds the platform plane and cannot be changed this way")
19+
ErrNotTerminated = errors.New("tenant is not terminated")
1920
)

‎backend/modules/tenant/handler/tenant.go‎

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,67 @@ func (h *TenantHandler) Terminate(c *gin.Context) {
181181
c.Status(http.StatusNoContent)
182182
}
183183

184+
// Reactivate godoc
185+
//
186+
// @Summary Reactivate a terminated tenant
187+
// @Description Flips a TERMINATED tenant back to ACTIVE. Fails if the tenant is not terminated.
188+
// @Tags Tenants
189+
// @Security BearerAuth
190+
// @Produce json
191+
// @Param id path string true "Tenant id"
192+
// @Success 200 {object} domain.Tenant
193+
// @Failure 400 {object} map[string]string
194+
// @Failure 403 {object} map[string]string
195+
// @Failure 404 {object} map[string]string
196+
// @Router /tenants/{id}/reactivate [post]
197+
func (h *TenantHandler) Reactivate(c *gin.Context) {
198+
tid, ok := pathTenantID(c)
199+
if !ok {
200+
return
201+
}
202+
t, err := h.uc.Reactivate(c.Request.Context(), tid)
203+
audit.Record(c, audit_connectors.Event{
204+
Action: "tenant.reactivate",
205+
ResourceType: "tenant",
206+
ResourceID: c.Param("id"),
207+
}, audit_domain.TENANT_REACTIVATE_ATTEMPT, audit_domain.TENANT_REACTIVATE_SUCCESS, err)
208+
if err != nil {
209+
writeError(c, err)
210+
return
211+
}
212+
c.JSON(http.StatusOK, t)
213+
}
214+
215+
// PermanentlyDelete godoc
216+
//
217+
// @Summary Permanently delete a terminated tenant
218+
// @Description Hard-deletes the tenant row and all rows scoped by tenant_id across the schema. Only allowed when the tenant is TERMINATED.
219+
// @Tags Tenants
220+
// @Security BearerAuth
221+
// @Param id path string true "Tenant id"
222+
// @Success 204
223+
// @Failure 400 {object} map[string]string
224+
// @Failure 403 {object} map[string]string
225+
// @Failure 404 {object} map[string]string
226+
// @Router /tenants/{id}/permanent [delete]
227+
func (h *TenantHandler) PermanentlyDelete(c *gin.Context) {
228+
tid, ok := pathTenantID(c)
229+
if !ok {
230+
return
231+
}
232+
err := h.uc.PermanentlyDelete(c.Request.Context(), tid)
233+
audit.Record(c, audit_connectors.Event{
234+
Action: "tenant.purge",
235+
ResourceType: "tenant",
236+
ResourceID: c.Param("id"),
237+
}, audit_domain.TENANT_PURGE_ATTEMPT, audit_domain.TENANT_PURGE_SUCCESS, err)
238+
if err != nil {
239+
writeError(c, err)
240+
return
241+
}
242+
c.Status(http.StatusNoContent)
243+
}
244+
184245
func writeError(c *gin.Context, err error) {
185246
switch {
186247
case errors.Is(err, domain.ErrNotFound):
@@ -192,7 +253,8 @@ func writeError(c *gin.Context, err error) {
192253
case errors.Is(err, domain.ErrNameRequired), errors.Is(err, domain.ErrDomainRequired),
193254
errors.Is(err, domain.ErrDomainInvalid), errors.Is(err, domain.ErrStatusInvalid),
194255
errors.Is(err, domain.ErrAlreadyTerminated), errors.Is(err, domain.ErrSupportInvalid),
195-
errors.Is(err, domain.ErrLimitNegative), errors.Is(err, domain.ErrLimitInvalid):
256+
errors.Is(err, domain.ErrLimitNegative), errors.Is(err, domain.ErrLimitInvalid),
257+
errors.Is(err, domain.ErrNotTerminated):
196258
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
197259
default:
198260
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})

‎backend/modules/tenant/module.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ type Module struct {
1515
bootstrapUC connectors.BootstrapUsecase
1616
}
1717

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

2222
return &Module{
2323
tenantHandler: handler.NewTenantHandler(tenantUC),

‎backend/modules/tenant/repository/tenant_pg.go‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package repository
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"github.com/google/uuid"
78

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

54+
func (r *pgTenantRepository) PurgeAllTenantData(ctx context.Context, id uuid.UUID) error {
55+
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
56+
var tables []string
57+
if err := tx.Raw(`
58+
SELECT table_name
59+
FROM information_schema.columns
60+
WHERE table_schema = current_schema()
61+
AND column_name = 'tenant_id'
62+
AND table_name <> 'tenant'
63+
`).Scan(&tables).Error; err != nil {
64+
return err
65+
}
66+
for _, tbl := range tables {
67+
sql := fmt.Sprintf(`DELETE FROM %q WHERE tenant_id::text = ?`, tbl)
68+
if err := tx.Exec(sql, id.String()).Error; err != nil {
69+
return err
70+
}
71+
}
72+
return nil
73+
})
74+
}
75+
5376
func (r *pgTenantRepository) List(ctx context.Context, f dto.Filter) ([]domain.Tenant, int64, error) {
5477
q := r.db.WithContext(ctx).Model(&domain.Tenant{})
5578

‎backend/modules/tenant/routes.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth, mssp, platform gi
1919
g.POST("", write, h.Create)
2020
g.PUT("/:id", write, h.Update)
2121
g.DELETE("/:id", write, h.Terminate)
22+
g.POST("/:id/reactivate", write, h.Reactivate)
23+
g.DELETE("/:id/permanent", write, h.PermanentlyDelete)
2224

2325
own := api.Group("/tenants", userAuth, mssp)
2426
ownTenant := []gin.HandlerFunc{middleware.RequireAdmin(), middleware.RequireOwnTenant("id")}

‎backend/modules/tenant/usecase/tenant.go‎

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@ import (
1818
const defaultPageSize = 25
1919

2020
type tenantUsecase struct {
21-
repo connectors.TenantRepository
22-
admin connectors.UserProvisioner
21+
repo connectors.TenantRepository
22+
admin connectors.UserProvisioner
23+
extras []connectors.TenantPurgeFunc
2324
}
2425

25-
func NewTenantUsecase(repo connectors.TenantRepository, admin connectors.UserProvisioner) connectors.TenantUsecase {
26-
return &tenantUsecase{repo: repo, admin: admin}
26+
func NewTenantUsecase(repo connectors.TenantRepository, admin connectors.UserProvisioner, extras []connectors.TenantPurgeFunc) connectors.TenantUsecase {
27+
return &tenantUsecase{repo: repo, admin: admin, extras: extras}
2728
}
2829

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

189+
func (u *tenantUsecase) Reactivate(ctx context.Context, id uuid.UUID) (*domain.Tenant, error) {
190+
if id.String() == authz.DefaultTenantID {
191+
return nil, domain.ErrDefaultTenant
192+
}
193+
t, err := u.GetByID(ctx, id)
194+
if err != nil {
195+
return nil, err
196+
}
197+
if t.Status != domain.StatusTerminated {
198+
return nil, domain.ErrNotTerminated
199+
}
200+
t.Status = domain.StatusActive
201+
if err := u.repo.Update(ctx, t); err != nil {
202+
return nil, err
203+
}
204+
return t, nil
205+
}
206+
207+
func (u *tenantUsecase) PermanentlyDelete(ctx context.Context, id uuid.UUID) error {
208+
if id.String() == authz.DefaultTenantID {
209+
return domain.ErrDefaultTenant
210+
}
211+
t, err := u.GetByID(ctx, id)
212+
if err != nil {
213+
return err
214+
}
215+
if t.Status != domain.StatusTerminated {
216+
return domain.ErrNotTerminated
217+
}
218+
for _, purge := range u.extras {
219+
if err := purge(ctx, id); err != nil {
220+
return fmt.Errorf("external purge: %w", err)
221+
}
222+
}
223+
if err := u.repo.PurgeAllTenantData(ctx, id); err != nil {
224+
return err
225+
}
226+
return u.repo.Delete(ctx, id)
227+
}
228+
188229
func (u *tenantUsecase) ResolveDomain(ctx context.Context, host string) (*domain.Tenant, error) {
189230
if h, _, err := net.SplitHostPort(host); err == nil {
190231
host = h
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { useState } from 'react'
2+
import { useTranslation } from 'react-i18next'
3+
import { Building2 } from 'lucide-react'
4+
import { toast } from 'sonner'
5+
import { Button } from '@/shared/components/ui/button'
6+
import { Input } from '@/shared/components/ui/input'
7+
import { tenantsHttpService } from '../services/tenants-http.service'
8+
import type { CreateTenantRequest } from '../types/tenant.types'
9+
import { Modal } from './Modal'
10+
import { Field } from './Field'
11+
import { tenantError } from './tenant-error'
12+
13+
export function CreateTenantDialog({
14+
onClose,
15+
onCreated,
16+
}: {
17+
onClose: () => void
18+
onCreated: () => void
19+
}) {
20+
const { t } = useTranslation()
21+
const [name, setName] = useState('')
22+
const [domain, setDomain] = useState('')
23+
const [adminEmail, setAdminEmail] = useState('')
24+
const [busy, setBusy] = useState(false)
25+
26+
const valid =
27+
name.trim().length >= 2 && domain.trim().length >= 3 && /.+@.+\..+/.test(adminEmail.trim())
28+
29+
const submit = async () => {
30+
if (!valid || busy) return
31+
setBusy(true)
32+
try {
33+
const body: CreateTenantRequest = {
34+
name: name.trim(),
35+
domain: domain.trim().toLowerCase(),
36+
adminEmail: adminEmail.trim(),
37+
}
38+
await tenantsHttpService.create(body)
39+
toast.success(t('tenants.toast.created'))
40+
onCreated()
41+
} catch (err) {
42+
toast.error(tenantError(err, t))
43+
} finally {
44+
setBusy(false)
45+
}
46+
}
47+
48+
return (
49+
<Modal
50+
title={t('tenants.create.title')}
51+
subtitle={t('tenants.create.subtitle')}
52+
icon={Building2}
53+
onClose={onClose}
54+
footer={
55+
<>
56+
<Button variant="outline" size="sm" onClick={onClose} disabled={busy}>
57+
{t('tenants.cancel')}
58+
</Button>
59+
<Button size="sm" disabled={!valid || busy} onClick={() => void submit()}>
60+
{busy ? t('tenants.saving') : t('tenants.create.submit')}
61+
</Button>
62+
</>
63+
}
64+
>
65+
<Field label={t('tenants.fields.name')}>
66+
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Acme Corp" />
67+
</Field>
68+
<Field label={t('tenants.fields.domain')} hint={t('tenants.fields.domainHint')}>
69+
<Input
70+
value={domain}
71+
onChange={(e) => setDomain(e.target.value)}
72+
className="font-mono"
73+
placeholder="acme.utmstack.com"
74+
/>
75+
</Field>
76+
<Field label={t('tenants.fields.adminEmail')} hint={t('tenants.fields.adminEmailHint')}>
77+
<Input
78+
type="email"
79+
value={adminEmail}
80+
onChange={(e) => setAdminEmail(e.target.value)}
81+
placeholder="admin@acme.com"
82+
/>
83+
</Field>
84+
</Modal>
85+
)
86+
}

0 commit comments

Comments
 (0)