diff --git a/cmd/core/main.go b/cmd/core/main.go index 6d189a4a..61933a31 100644 --- a/cmd/core/main.go +++ b/cmd/core/main.go @@ -11,6 +11,8 @@ import ( "gitea.perlbach24.de/scripte/nexarch/internal/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/svcauth" "gitea.perlbach24.de/scripte/nexarch/internal/tenant" + "gitea.perlbach24.de/scripte/nexarch/internal/tenantimportdir" + "gitea.perlbach24.de/scripte/nexarch/internal/tenantimportdirapi" "gitea.perlbach24.de/scripte/nexarch/internal/user" ) @@ -53,11 +55,16 @@ func main() { superadmins := user.NewSuperadminStore(registryPool).WithAudit(auditLog) userHandler := user.NewHandler(nil, superadmins) + // TEN-12: Abfrage der Import-Verzeichnis-Zuordnung fuer Module. + importDirStore := tenantimportdir.NewStore(registryPool, registry, superadmins, auditLog) + importDirHandler := tenantimportdirapi.NewHandler(importDirStore) + mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) registerInternalRoutes(mux, adminToken, tenantHandler.CreateTenant, onboardingHandler.Onboard, userHandler.CreateSuperadmin) + registerImportDirectoryQueryRoutes(mux, adminToken, importDirHandler.ListForTenantHandler, importDirHandler.ResolveHandler) log.Printf("nexarch-core listening on %s", cfg.ListenAddr) if err := httpharden.ListenAndServe(cfg.ListenAddr, mux); err != nil { diff --git a/cmd/core/routes.go b/cmd/core/routes.go index d7c29f48..0f91e26e 100644 --- a/cmd/core/routes.go +++ b/cmd/core/routes.go @@ -15,3 +15,12 @@ func registerInternalRoutes(mux *http.ServeMux, token string, createTenant, onbo mux.HandleFunc("/internal/tenants/onboard", svcauth.Require(token, onboard)) mux.HandleFunc("/internal/superadmins", svcauth.Require(token, createSuperadmin)) } + +// registerImportDirectoryQueryRoutes bindet TEN-12s Abfrageendpunkte fuer +// Module (DMS/Mail/Archive) ein — derselbe Dienst-Token wie die uebrigen +// internen Routen dieses Dienstes ("bestehender Service-Token-Zugriff aus +// TEN-01" laut Ticket). +func registerImportDirectoryQueryRoutes(mux *http.ServeMux, token string, listForTenant, resolve http.HandlerFunc) { + mux.HandleFunc("/internal/tenants/{slug}/import-directories", svcauth.Require(token, listForTenant)) + mux.HandleFunc("/internal/import-directories/resolve", svcauth.Require(token, resolve)) +} diff --git a/cmd/tenantadmin-devserver/main.go b/cmd/tenantadmin-devserver/main.go index c73afa06..65394811 100644 --- a/cmd/tenantadmin-devserver/main.go +++ b/cmd/tenantadmin-devserver/main.go @@ -18,6 +18,8 @@ import ( "gitea.perlbach24.de/scripte/nexarch/internal/svcauth" "gitea.perlbach24.de/scripte/nexarch/internal/tenant" "gitea.perlbach24.de/scripte/nexarch/internal/tenantadmin" + "gitea.perlbach24.de/scripte/nexarch/internal/tenantimportdir" + "gitea.perlbach24.de/scripte/nexarch/internal/tenantimportdirapi" "gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings" "gitea.perlbach24.de/scripte/nexarch/internal/user" ) @@ -51,8 +53,14 @@ func main() { superadmins := user.NewSuperadminStore(pool).WithAudit(auditLog) handler := tenantadmin.NewHandler(registry, lifecycle, settingsStore, superadmins) + // TEN-12: Verwaltung der Import-Verzeichnis-Zuordnungen ueber dieselbe + // Konsole, dieselbe Superadmin-Pruefung. + importDirStore := tenantimportdir.NewStore(pool, registry, superadmins, auditLog) + importDirHandler := tenantimportdirapi.NewHandler(importDirStore) + mux := http.NewServeMux() registerAdminRoutes(mux, adminToken, corsOrigins, handler.ListTenantsHandler, handler.TenantDetailHandler, handler.UpdateSettingsHandler, handler.LifecycleActionHandler) + registerImportDirectoryRoutes(mux, adminToken, corsOrigins, importDirHandler.AddHandler, importDirHandler.RemoveHandler) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) log.Printf("tenantadmin-devserver listening on %s", addr) diff --git a/cmd/tenantadmin-devserver/routes.go b/cmd/tenantadmin-devserver/routes.go index 2be6ec5e..81205265 100644 --- a/cmd/tenantadmin-devserver/routes.go +++ b/cmd/tenantadmin-devserver/routes.go @@ -16,3 +16,24 @@ func registerAdminRoutes(mux *http.ServeMux, token string, origins []string, lis mux.HandleFunc("/admin/tenants/settings", svcauth.Protect(token, origins, "POST, OPTIONS", settings)) mux.HandleFunc("/admin/tenants/lifecycle", svcauth.Protect(token, origins, "POST, OPTIONS", lifecycle)) } + +// registerImportDirectoryRoutes bindet TEN-12s Verwaltungsendpunkte (Add/ +// Remove) ein — dieselbe Superadmin-per-Parameter-Authentifizierung wie +// registerAdminRoutes, siehe internal/tenantimportdir.Store.requireSuperadmin. +// EIN unpraefigiertes Pfadmuster (wie die uebrigen Routen dieser Datei), +// die Methode wird im Handler unterschieden — ein methodenpraefigiertes +// Muster ("POST /..."/"DELETE /...") wuerde die OPTIONS-Preflight-Anfrage +// der CORS-Behandlung (svcauth.CORS) am Mux vorbei mit 405 beantworten, +// bevor sie den Handler ueberhaupt erreicht. +func registerImportDirectoryRoutes(mux *http.ServeMux, token string, origins []string, add, remove http.HandlerFunc) { + mux.HandleFunc("/admin/tenants/{slug}/import-directories", svcauth.Protect(token, origins, "POST, DELETE, OPTIONS", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + add(w, r) + case http.MethodDelete: + remove(w, r) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) +} diff --git a/docs/TEN-12-PRUEFPROTOKOLL.md b/docs/TEN-12-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..28c72b3e --- /dev/null +++ b/docs/TEN-12-PRUEFPROTOKOLL.md @@ -0,0 +1,42 @@ +# TEN-12 – Prüfprotokoll: Import-Verzeichnisse je Tenant in der Registry führen + +Branch `feature/ten-12-import-verzeichnisse-je-tenant-in-der-registry-fuehren`, Basis `integration/gesamtprojekt` (Head `27d3dca`). Geprüft auf Entwicklungshost 204 (Wegwerf-Cluster `17/coredev`, `TEST_ADMIN_DSN`), `archivdms` unberührt, nichts installiert. + +## Umsetzung + +- Migration `migrations/0017_tenant_import_directories.{up,down}.sql`: Tabelle `tenant_import_directories` (`tenant_id` FK, `path` global `UNIQUE`, `purpose` mit `CHECK` auf `consume`/`scanner`). +- Neues Paket `internal/tenantimportdir` (Domäne + Store): `Add`, `Remove` (nur aktive Superadmins, Audit-Eintrag), `ListForTenant`, `Resolve` (längster Präfix, inaktiver Tenant zählt als nicht gefunden). +- Neues Paket `internal/tenantimportdirapi` (HTTP-Bindung). +- Verwaltung (`Add`/`Remove`) in `cmd/tenantadmin-devserver` unter `/admin/tenants/{slug}/import-directories` — dieselbe Superadmin-per-Parameter-Prüfung wie die übrigen TEN-05-Endpunkte dieser Konsole, dienst-token-geschützt (`svcauth.Protect`, `NEXARCH_TENANTADMIN_ADMIN_TOKEN`). Ein Pfad, POST/DELETE per Methodenverzweigung im Handler statt zweier methodenpräfigierter Mux-Muster — Letzteres hätte die CORS-Preflight-Antwort (OPTIONS) am Mux vorbeigeführt, siehe Kommentar in `cmd/tenantadmin-devserver/routes.go`. +- Abfrage (`ListForTenant`/`Resolve`) in `cmd/core` unter `/internal/tenants/{slug}/import-directories` und `/internal/import-directories/resolve` — derselbe bestehende Dienst-Token wie die übrigen internen Core-Routen (`NEXARCH_CORE_ADMIN_TOKEN`), wie im Ticket unter "Technische Grundlage" gefordert ("bestehender Service-Token-Zugriff aus TEN-01"). +- Keine neuen Umgebungsvariablen: beide Tokens existieren bereits. + +## Entscheidungen (Ticket-Text maßgeblich, hier dokumentiert) + +- **Verschachtelungsprüfung nur über Tenants hinweg**, wie im Ticket qualifiziert ("auch als Präfix: ... nie zwei Tenants"). Innerhalb desselben Tenants ist Verschachtelung erlaubt (z. B. `/data/acme` und `/data/acme/scan` für denselben Tenant) — es gibt keinen Konflikt, wenn ein Verzeichnis sich selbst untersteht. +- **`Resolve` matcht per längstem Präfix**, nicht nur exakt: Das Ticket verlangt "die Abfrage nach Pfad liefert den Tenant"; ein Datei-Ereignis unterhalb eines registrierten Verzeichnisses (z. B. `/data/acme/scan/2026/beleg.pdf` unter `/data/acme/scan`) muss denselben Tenant liefern wie das Verzeichnis selbst, sonst wäre die Abfrage für den eigentlichen Anwendungsfall (Datei-Import-Ereignisse) nutzlos. +- **Nicht Bestandteil (laut Ticket): DMS-Umstellung.** `dms/internal/importdir` (INT-14) bleibt unverändert bei der lokalen Registry-Datei; die Core-Abfrage ist eine Folgekachel. +- **Kein neuer Dienst-Token**: Wiederverwendung der beiden bestehenden Tokens aus `cmd/core` und `cmd/tenantadmin-devserver`, damit "eine Quelle der Wahrheit" (Produkt-DNA) nicht durch eine zweite Auth-Konfiguration konterkariert wird. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Doppelzuordnung und verschachtelte Pfade werden abgelehnt | `TestAdd_RejectsDuplicateAndCrossTenantNesting`: exakter Doppeleintrag → `ErrPathTaken`; fremder Tenant unterhalb/oberhalb eines bestehenden Pfads → `ErrNestedPath`; Verschachtelung innerhalb desselben Tenants → erlaubt. **PASS** | +| 2 | Abfrage mit Service-Token liefert den richtigen Tenant, ohne Token 401, unbekannter Pfad 404 | Service-Token-Schutz ist `svcauth.Require` (bereits durch `TestInternalRoutes_RequireServiceToken`/`TestAdminRoutes_RequireTokenAndIgnoreSuperadminParameterWithoutIt` in `cmd/core`/`cmd/tenantadmin-devserver` abgedeckt, unverändert grün); `TestResolve_LongestPrefixAndNotFound`: richtiger Tenant je Präfixtiefe, fremder Tenant bleibt fremd, unbekannter Pfad → `ErrNotFound` (→ 404 laut `writeErr`), echter Transportfehler (Pool geschlossen) liefert einen von `ErrNotFound` unterscheidbaren Fehler. **PASS** | +| 3 | Gesperrter Tenant liefert 404 für seine Verzeichnisse | `TestResolve_SuspendedTenantIsInvalid`: nach `registry.Suspend` liefert `Resolve` `ErrNotFound`, obwohl der Registry-Eintrag weiterhin existiert (`ListForTenant` zeigt ihn unverändert für die Verwaltungssicht). **PASS** | + +Zusätzlich: `TestAdd_Remove_RequireSuperadminAndAudit` (Akzeptanzkriterium 2: leerer/unbekannter Akteur → `ErrNotSuperadmin`, Audit-Zeile je Add/Remove in `audit_events`, erneutes Entfernen → `ErrNotFound`), `TestNormalizePath_RejectsInvalid` (leer/relativ/Wurzel/`.` → `ErrInvalidPath`; `..` wird von `filepath.Clean` lexikalisch aufgelöst, kein Traversal, siehe Kommentar im Test), `TestAdd_UnknownTenant` (`ErrTenantNotFound`). + +## Läufe auf 204 + +- `go build ./...`, `go vet ./...`: sauber. +- `gofmt -l .`: nur Bestandsdateien (13, keine aus diesem Branch). +- `go test -count=1 ./internal/tenantimportdir/... ./internal/tenantimportdirapi/... ./cmd/core/... ./cmd/tenantadmin-devserver/...`: 6/6 neue Tests PASS, bestehende Routen-Tests unverändert PASS. +- `go test -count=1 ./...` (ganzes Repo): 0 FAIL. +- `scripts/e2e-core-dms.sh`: 1. Lauf ein Fehlschlag (`login alice@e2e.test: status 401`, transiente Störung durch parallele Forks auf demselben Wegwerf-Cluster — dieser Branch berührt `account-api`/`apiwiring`/Login/JWKS nicht), 2. Lauf 10/10 PASS. Kein eigener Regressionsfund. + +## Nicht Bestandteil dieser Kachel (laut Ticket, hier nicht umgesetzt) + +- Umstellung des DMS auf die Core-Abfrage (eigene INT-14-Folgekachel). +- Dateisystemrechte der Verzeichnisse (DMS-Betriebsanleitung). diff --git a/internal/tenantimportdir/tenantimportdir.go b/internal/tenantimportdir/tenantimportdir.go new file mode 100644 index 00000000..0efc724f --- /dev/null +++ b/internal/tenantimportdir/tenantimportdir.go @@ -0,0 +1,346 @@ +// Package tenantimportdir implementiert Core TEN-12: die zentrale +// Registry-Zuordnung Import-Verzeichnis -> Tenant. Ersetzt schrittweise die +// lokale Registry-Datei, die das DMS (INT-14) bis zur Umstellung fuehrt +// (dms/docs/BETRIEB-IMPORT-VERZEICHNISSE.md, siehe TEN-12 "Nicht Bestandteil"). +package tenantimportdir + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/audit" + "gitea.perlbach24.de/scripte/nexarch/internal/tenant" + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +// ErrNotFound deckt sowohl "kein Verzeichnis unter diesem Pfad registriert" +// als auch "registriert, aber der Tenant ist nicht aktiv" ab (Akzeptanz- +// kriterium 4: ein gesperrter/geloeschter Tenant macht seine Verzeichnisse +// fuer die Abfrage ungueltig) — beide Faelle sind fuer den abfragenden +// Modul-Client gleich zu behandeln, siehe Ticket Pruefung 3. +var ErrNotFound = errors.New("tenantimportdir: kein aktiver tenant fuer diesen pfad registriert") + +// ErrTenantNotFound wird geliefert, wenn Add/Remove/List sich auf einen +// unbekannten Tenant-Slug beziehen. +var ErrTenantNotFound = errors.New("tenantimportdir: tenant unbekannt") + +// ErrInvalidPath wird geliefert, wenn der Pfad kein bereinigter absoluter +// Pfad ungleich der Wurzel ist. +var ErrInvalidPath = errors.New("tenantimportdir: pfad muss ein bereinigter absoluter pfad ungleich '/' sein") + +// ErrInvalidPurpose wird geliefert, wenn der Zweck nicht "consume" oder +// "scanner" ist (siehe DB-CHECK-Constraint, hier zusaetzlich vorab geprueft +// fuer eine klare Fehlermeldung statt einer rohen Constraint-Verletzung). +var ErrInvalidPurpose = errors.New("tenantimportdir: zweck muss 'consume' oder 'scanner' sein") + +// ErrPathTaken wird geliefert, wenn der exakte Pfad bereits einem (ggf. +// demselben) Tenant zugeordnet ist (Akzeptanzkriterium 1, erster Halbsatz). +var ErrPathTaken = errors.New("tenantimportdir: pfad ist bereits zugeordnet") + +// ErrNestedPath wird geliefert, wenn der Pfad in einen bestehenden Pfad +// EINES ANDEREN Tenants hineinreicht oder ihn enthaelt (Akzeptanzkriterium 1, +// zweiter Halbsatz: "auch als Praefix ... ueber Tenants hinweg"). Innerhalb +// desselben Tenants ist Verschachtelung bewusst erlaubt (das Ticket +// qualifiziert die Praefixpruefung ausdruecklich mit "ueber Tenants hinweg"). +var ErrNestedPath = errors.New("tenantimportdir: pfad ueberschneidet sich mit dem verzeichnis eines anderen tenants") + +// ErrNotSuperadmin wird geliefert, wenn Add/Remove von einem Aufrufer ohne +// aktive Superadmin-Identitaet versucht werden (Akzeptanzkriterium 2). +var ErrNotSuperadmin = errors.New("tenantimportdir: aufrufer ist kein aktiver superadmin") + +type Purpose string + +const ( + PurposeConsume Purpose = "consume" + PurposeScanner Purpose = "scanner" +) + +func (p Purpose) valid() bool { + return p == PurposeConsume || p == PurposeScanner +} + +// Directory ist eine registrierte Zuordnung Verzeichnis -> Tenant. +type Directory struct { + ID string + TenantSlug string + Path string + Purpose Purpose + CreatedAt time.Time +} + +// Store kapselt den Datenbankzugriff. superadmins darf nicht nil sein — die +// Store-Konstruktion erzwingt damit, dass Add/Remove nie ohne +// Berechtigungspruefung verdrahtet werden koennen (Fail-Safe-Default wie +// internal/tenantadmin). +type Store struct { + pool *pgxpool.Pool + registry *tenant.Registry + superadmins *user.SuperadminStore + audit *audit.Log +} + +func NewStore(pool *pgxpool.Pool, registry *tenant.Registry, superadmins *user.SuperadminStore, auditLog *audit.Log) *Store { + return &Store{pool: pool, registry: registry, superadmins: superadmins, audit: auditLog} +} + +// normalizePath erzwingt einen bereinigten, absoluten Pfad ungleich der +// Wurzel — verhindert sowohl relative Pfade (nicht filesystemweit eindeutig) +// als auch, dass "/" versehentlich saemtliche Importe eines Hosts einem +// einzigen Tenant zuordnet. +func normalizePath(raw string) (string, error) { + if raw == "" || !strings.HasPrefix(raw, "/") { + return "", ErrInvalidPath + } + // filepath.Clean loest "." und ".." bereits lexikalisch auf (fuer + // absolute Pfade bleibt dabei nie ein ".." im Ergebnis stehen) und + // entfernt einen etwaigen Endschraegstrich. + clean := filepath.Clean(raw) + if clean == "/" { + return "", ErrInvalidPath + } + return clean, nil +} + +func (s *Store) requireSuperadmin(ctx context.Context, superadminID string) error { + if superadminID == "" { + return ErrNotSuperadmin + } + admin, err := s.superadmins.GetAsActor(ctx, superadminID, superadminID) + if err != nil { + return ErrNotSuperadmin + } + if admin.Status != user.StatusActive { + return ErrNotSuperadmin + } + return nil +} + +// nested prueft, ob a und b im Praefixsinn ineinander liegen (a==b zaehlt +// hier NICHT als "nested" — das ist der Exact-Duplicate-Fall, ueber die +// UNIQUE-Constraint bzw. ErrPathTaken abgedeckt). +func nested(a, b string) bool { + return strings.HasPrefix(a+"/", b+"/") || strings.HasPrefix(b+"/", a+"/") +} + +// Add registriert ein Verzeichnis fuer einen Tenant. Nur fuer aktive +// Superadmins (Akzeptanzkriterium 2), mit Audit-Eintrag. +func (s *Store) Add(ctx context.Context, actor, tenantSlug, path string, purpose Purpose) (Directory, error) { + if err := s.requireSuperadmin(ctx, actor); err != nil { + return Directory{}, err + } + if !purpose.valid() { + return Directory{}, ErrInvalidPurpose + } + clean, err := normalizePath(path) + if err != nil { + return Directory{}, err + } + + t, err := s.registry.GetBySlug(ctx, tenantSlug) + if err != nil { + return Directory{}, fmt.Errorf("%w: %s", ErrTenantNotFound, tenantSlug) + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return Directory{}, fmt.Errorf("tenantimportdir: transaktion starten: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + // Verschachtelungspruefung ueber Tenants hinweg (Akzeptanzkriterium 1). + // Sperrt die betroffenen Zeilen fuer die Dauer der Transaktion, damit + // zwei gleichzeitige Add-Aufrufe fuer sich ueberschneidende Pfade nicht + // beide die Pruefung passieren, bevor einer committet. + rows, err := tx.Query(ctx, `SELECT path FROM tenant_import_directories WHERE tenant_id <> $1 FOR UPDATE`, t.ID) + if err != nil { + return Directory{}, fmt.Errorf("tenantimportdir: bestehende pfade lesen: %w", err) + } + var others []string + for rows.Next() { + var p string + if err := rows.Scan(&p); err != nil { + rows.Close() + return Directory{}, fmt.Errorf("tenantimportdir: pfad lesen: %w", err) + } + others = append(others, p) + } + rows.Close() + if err := rows.Err(); err != nil { + return Directory{}, fmt.Errorf("tenantimportdir: pfade lesen: %w", err) + } + for _, other := range others { + if nested(clean, other) { + return Directory{}, ErrNestedPath + } + } + + var d Directory + row := tx.QueryRow(ctx, ` + INSERT INTO tenant_import_directories (tenant_id, path, purpose) + VALUES ($1, $2, $3) + RETURNING id, created_at + `, t.ID, clean, string(purpose)) + if err := row.Scan(&d.ID, &d.CreatedAt); err != nil { + if isUniqueViolation(err) { + return Directory{}, ErrPathTaken + } + return Directory{}, fmt.Errorf("tenantimportdir: verzeichnis anlegen: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return Directory{}, fmt.Errorf("tenantimportdir: transaktion committen: %w", err) + } + + d.TenantSlug = tenantSlug + d.Path = clean + d.Purpose = purpose + + if s.audit != nil { + _ = s.audit.Record(ctx, audit.Event{ + TenantSlug: tenantSlug, + Actor: actor, + Action: "import_directory.added", + Target: clean, + Metadata: map[string]any{"purpose": string(purpose)}, + }) + } + return d, nil +} + +// Remove entfernt die Zuordnung eines Pfads fuer einen Tenant. Nur fuer +// aktive Superadmins, mit Audit-Eintrag. ErrNotFound, wenn der Pfad diesem +// Tenant nicht zugeordnet ist. +func (s *Store) Remove(ctx context.Context, actor, tenantSlug, path string) error { + if err := s.requireSuperadmin(ctx, actor); err != nil { + return err + } + clean, err := normalizePath(path) + if err != nil { + return err + } + t, err := s.registry.GetBySlug(ctx, tenantSlug) + if err != nil { + return fmt.Errorf("%w: %s", ErrTenantNotFound, tenantSlug) + } + + tag, err := s.pool.Exec(ctx, ` + DELETE FROM tenant_import_directories WHERE tenant_id = $1 AND path = $2 + `, t.ID, clean) + if err != nil { + return fmt.Errorf("tenantimportdir: verzeichnis entfernen: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + + if s.audit != nil { + _ = s.audit.Record(ctx, audit.Event{ + TenantSlug: tenantSlug, + Actor: actor, + Action: "import_directory.removed", + Target: clean, + }) + } + return nil +} + +// ListForTenant liefert alle Verzeichnisse eines Tenants, unabhaengig vom +// Tenant-Status (fuer die Verwaltungsoberflaeche bzw. Modul-Abfrage nach +// Slug — Aktivitaetspruefung ist Sache von Resolve, siehe dort). +func (s *Store) ListForTenant(ctx context.Context, tenantSlug string) ([]Directory, error) { + t, err := s.registry.GetBySlug(ctx, tenantSlug) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrTenantNotFound, tenantSlug) + } + rows, err := s.pool.Query(ctx, ` + SELECT id, path, purpose, created_at FROM tenant_import_directories + WHERE tenant_id = $1 ORDER BY path + `, t.ID) + if err != nil { + return nil, fmt.Errorf("tenantimportdir: verzeichnisse auflisten: %w", err) + } + defer rows.Close() + + var out []Directory + for rows.Next() { + var d Directory + var purpose string + if err := rows.Scan(&d.ID, &d.Path, &purpose, &d.CreatedAt); err != nil { + return nil, fmt.Errorf("tenantimportdir: verzeichnis lesen: %w", err) + } + d.TenantSlug = tenantSlug + d.Purpose = Purpose(purpose) + out = append(out, d) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("tenantimportdir: verzeichnisse auflisten: %w", err) + } + return out, nil +} + +// Resolve liefert den Tenant zu einem aufgeloesten Dateisystempfad — exakter +// Treffer zuerst, sonst der laengste registrierte Praefix (Akzeptanz- +// kriterium 3). Verzeichnisse eines nicht aktiven Tenants (suspendiert, +// zur Loeschung vorgemerkt, geloescht) gelten als nicht gefunden +// (Akzeptanzkriterium 4). Jeder andere Fehler (DB/Transport) wird +// unveraendert durchgereicht, damit der Aufrufer ihn von ErrNotFound +// unterscheiden kann (Pruefung 3). +func (s *Store) Resolve(ctx context.Context, path string) (Directory, error) { + clean, err := normalizePath(path) + if err != nil { + return Directory{}, ErrNotFound + } + + rows, err := s.pool.Query(ctx, ` + SELECT d.id, d.path, d.purpose, d.created_at, t.slug, t.status + FROM tenant_import_directories d + JOIN tenants t ON t.id = d.tenant_id + `) + if err != nil { + return Directory{}, fmt.Errorf("tenantimportdir: verzeichnisse aufloesen: %w", err) + } + defer rows.Close() + + var best Directory + var bestStatus string + bestLen := -1 + for rows.Next() { + var d Directory + var purpose, slug, status string + if err := rows.Scan(&d.ID, &d.Path, &purpose, &d.CreatedAt, &slug, &status); err != nil { + return Directory{}, fmt.Errorf("tenantimportdir: verzeichnis lesen: %w", err) + } + if clean != d.Path && !strings.HasPrefix(clean+"/", d.Path+"/") { + continue + } + if len(d.Path) > bestLen { + bestLen = len(d.Path) + d.TenantSlug = slug + d.Purpose = Purpose(purpose) + best = d + bestStatus = status + } + } + if err := rows.Err(); err != nil { + return Directory{}, fmt.Errorf("tenantimportdir: verzeichnisse aufloesen: %w", err) + } + if bestLen < 0 { + return Directory{}, ErrNotFound + } + if bestStatus != string(tenant.StatusActive) { + return Directory{}, ErrNotFound + } + return best, nil +} + +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} diff --git a/internal/tenantimportdir/tenantimportdir_test.go b/internal/tenantimportdir/tenantimportdir_test.go new file mode 100644 index 00000000..4f9ac52e --- /dev/null +++ b/internal/tenantimportdir/tenantimportdir_test.go @@ -0,0 +1,309 @@ +package tenantimportdir + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/audit" + "gitea.perlbach24.de/scripte/nexarch/internal/tenant" + "gitea.perlbach24.de/scripte/nexarch/internal/testdbisolation" + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +// migratedPool liefert einen Pool auf eine frische, per echter Migration +// aufgebaute Registry-Datenbank (gleiches Muster wie +// internal/policy/dms_rights_test.go). Ohne TEST_ADMIN_DSN wird der Test +// uebersprungen, nicht als fehlgeschlagen gewertet. +func migratedPool(t *testing.T) *pgxpool.Pool { + t.Helper() + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + name := fmt.Sprintf("ten12_%d", time.Now().UnixNano()) + cleanup, err := testdbisolation.ProvisionEphemeralDB(ctx, adminDSN, name) + if err != nil { + t.Fatal(err) + } + cfg, err := pgxpool.ParseConfig(adminDSN) + if err != nil { + t.Fatal(err) + } + cfg.ConnConfig.Database = name + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + pool.Close() + _ = cleanup(context.Background()) + }) + + files, _ := filepath.Glob(filepath.Join("..", "..", "migrations", "*.up.sql")) + sort.Strings(files) + for _, f := range files { + sqlText, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, string(sqlText)); err != nil { + t.Fatalf("migration %s: %v", filepath.Base(f), err) + } + } + return pool +} + +type fixture struct { + pool *pgxpool.Pool + store *Store + registry *tenant.Registry + superadmins *user.SuperadminStore + auditLog *audit.Log + admin string // aktiver superadmin-akteur +} + +func setup(t *testing.T) fixture { + t.Helper() + pool := migratedPool(t) + ctx := context.Background() + + auditLog := audit.NewLog(pool) + registry := tenant.NewRegistry(pool).WithAudit(auditLog) + superadmins := user.NewSuperadminStore(pool).WithAudit(auditLog) + store := NewStore(pool, registry, superadmins, auditLog) + + admin, err := superadmins.Create(ctx, "admin@example.test", "Test Admin") + if err != nil { + t.Fatalf("superadmin anlegen: %v", err) + } + + return fixture{pool: pool, store: store, registry: registry, superadmins: superadmins, auditLog: auditLog, admin: admin.ID} +} + +func insertTenant(t *testing.T, pool *pgxpool.Pool, slug string) { + t.Helper() + _, err := pool.Exec(context.Background(), ` + INSERT INTO tenants (slug, name, db_name, db_dsn, status) + VALUES ($1, $1, $2, 'postgresql://x', 'active') + `, slug, "db_"+slug) + if err != nil { + t.Fatalf("tenant %s anlegen: %v", slug, err) + } +} + +func auditCount(t *testing.T, pool *pgxpool.Pool, action, target string) int { + t.Helper() + var n int + if err := pool.QueryRow(context.Background(), + `SELECT count(*) FROM audit_events WHERE action = $1 AND target = $2`, action, target, + ).Scan(&n); err != nil { + t.Fatalf("audit zaehlen: %v", err) + } + return n +} + +// Akzeptanzkriterium 1 / Pruefung 1: Doppelzuordnung und verschachtelte +// Pfade werden ueber Tenants hinweg abgelehnt; innerhalb desselben Tenants +// ist Verschachtelung erlaubt (Ticket qualifiziert die Praefixpruefung +// ausdruecklich mit "ueber Tenants hinweg"). +func TestAdd_RejectsDuplicateAndCrossTenantNesting(t *testing.T) { + f := setup(t) + ctx := context.Background() + insertTenant(t, f.pool, "acme") + insertTenant(t, f.pool, "globex") + + if _, err := f.store.Add(ctx, f.admin, "acme", "/data/acme/consume", PurposeConsume); err != nil { + t.Fatalf("erstes verzeichnis: %v", err) + } + + // exakter Doppeleintrag, auch unter demselben Tenant. + if _, err := f.store.Add(ctx, f.admin, "acme", "/data/acme/consume", PurposeScanner); !errors.Is(err, ErrPathTaken) { + t.Fatalf("erwartet ErrPathTaken, habe %v", err) + } + + // fremder Tenant, Verzeichnis liegt unterhalb des bestehenden Pfads. + if _, err := f.store.Add(ctx, f.admin, "globex", "/data/acme/consume/sub", PurposeConsume); !errors.Is(err, ErrNestedPath) { + t.Fatalf("erwartet ErrNestedPath (unterhalb), habe %v", err) + } + // fremder Tenant, Verzeichnis liegt oberhalb des bestehenden Pfads. + if _, err := f.store.Add(ctx, f.admin, "globex", "/data/acme", PurposeConsume); !errors.Is(err, ErrNestedPath) { + t.Fatalf("erwartet ErrNestedPath (oberhalb), habe %v", err) + } + + // derselbe Tenant darf innerhalb seines eigenen Verzeichnisses ein + // weiteres registrieren. + if _, err := f.store.Add(ctx, f.admin, "acme", "/data/acme/consume/scan", PurposeScanner); err != nil { + t.Fatalf("verschachtelung innerhalb desselben tenants sollte erlaubt sein: %v", err) + } + + // unbeteiligter Pfad fuer einen anderen Tenant bleibt unberuehrt. + if _, err := f.store.Add(ctx, f.admin, "globex", "/data/globex/consume", PurposeConsume); err != nil { + t.Fatalf("unabhaengiger pfad: %v", err) + } +} + +// Akzeptanzkriterium 2: Anlegen/Entfernen nur fuer Superadmins, mit +// Audit-Eintrag. +func TestAdd_Remove_RequireSuperadminAndAudit(t *testing.T) { + f := setup(t) + ctx := context.Background() + insertTenant(t, f.pool, "acme") + + if _, err := f.store.Add(ctx, "", "acme", "/data/acme/consume", PurposeConsume); !errors.Is(err, ErrNotSuperadmin) { + t.Fatalf("leerer akteur: erwartet ErrNotSuperadmin, habe %v", err) + } + if _, err := f.store.Add(ctx, "unbekannt", "acme", "/data/acme/consume", PurposeConsume); !errors.Is(err, ErrNotSuperadmin) { + t.Fatalf("unbekannter akteur: erwartet ErrNotSuperadmin, habe %v", err) + } + + if _, err := f.store.Add(ctx, f.admin, "acme", "/data/acme/consume", PurposeConsume); err != nil { + t.Fatalf("add: %v", err) + } + if n := auditCount(t, f.pool, "import_directory.added", "/data/acme/consume"); n != 1 { + t.Fatalf("audit-eintrag fuer added fehlt, count=%d", n) + } + + if err := f.store.Remove(ctx, "", "acme", "/data/acme/consume"); !errors.Is(err, ErrNotSuperadmin) { + t.Fatalf("remove ohne akteur: erwartet ErrNotSuperadmin, habe %v", err) + } + if err := f.store.Remove(ctx, f.admin, "acme", "/data/acme/consume"); err != nil { + t.Fatalf("remove: %v", err) + } + if n := auditCount(t, f.pool, "import_directory.removed", "/data/acme/consume"); n != 1 { + t.Fatalf("audit-eintrag fuer removed fehlt, count=%d", n) + } + if err := f.store.Remove(ctx, f.admin, "acme", "/data/acme/consume"); !errors.Is(err, ErrNotFound) { + t.Fatalf("erneutes entfernen: erwartet ErrNotFound, habe %v", err) + } +} + +// Akzeptanzkriterium 3 / Pruefung 2: Abfrage liefert den richtigen Tenant, +// nie einen Treffer fuer einen fremden Tenant; laengster Praefix gewinnt; +// unbekannter Pfad ErrNotFound (getrennt von echten Transportfehlern). +func TestResolve_LongestPrefixAndNotFound(t *testing.T) { + f := setup(t) + ctx := context.Background() + insertTenant(t, f.pool, "acme") + insertTenant(t, f.pool, "globex") + + if _, err := f.store.Add(ctx, f.admin, "acme", "/data/acme", PurposeConsume); err != nil { + t.Fatalf("add acme: %v", err) + } + if _, err := f.store.Add(ctx, f.admin, "acme", "/data/acme/scan", PurposeScanner); err != nil { + t.Fatalf("add acme/scan: %v", err) + } + if _, err := f.store.Add(ctx, f.admin, "globex", "/data/globex", PurposeConsume); err != nil { + t.Fatalf("add globex: %v", err) + } + + // exakter Treffer auf dem laengeren, spezifischeren Verzeichnis. + d, err := f.store.Resolve(ctx, "/data/acme/scan") + if err != nil || d.TenantSlug != "acme" || d.Path != "/data/acme/scan" { + t.Fatalf("resolve exakt: %+v, %v", d, err) + } + + // Datei unterhalb des spezifischeren Verzeichnisses -> dieses gewinnt, + // nicht das kuerzere /data/acme. + d, err = f.store.Resolve(ctx, "/data/acme/scan/2026/beleg.pdf") + if err != nil || d.TenantSlug != "acme" || d.Path != "/data/acme/scan" { + t.Fatalf("resolve laengster praefix: %+v, %v", d, err) + } + + // Datei unterhalb des allgemeineren Verzeichnisses, aber nicht unter dem + // spezifischeren. + d, err = f.store.Resolve(ctx, "/data/acme/other/beleg.pdf") + if err != nil || d.TenantSlug != "acme" || d.Path != "/data/acme" { + t.Fatalf("resolve allgemeiner praefix: %+v, %v", d, err) + } + + // fremder Tenant bleibt fremd. + d, err = f.store.Resolve(ctx, "/data/globex/beleg.pdf") + if err != nil || d.TenantSlug != "globex" { + t.Fatalf("resolve globex: %+v, %v", d, err) + } + + // unbekannter Pfad. + if _, err := f.store.Resolve(ctx, "/data/unbekannt/beleg.pdf"); !errors.Is(err, ErrNotFound) { + t.Fatalf("unbekannter pfad: erwartet ErrNotFound, habe %v", err) + } + + // echter Transportfehler ist von "nicht gefunden" unterscheidbar. + f.pool.Close() + if _, err := f.store.Resolve(ctx, "/data/acme/scan"); err == nil || errors.Is(err, ErrNotFound) { + t.Fatalf("transportfehler haette NICHT ErrNotFound sein duerfen, habe %v", err) + } +} + +// Akzeptanzkriterium 4 / Pruefung 3: ein gesperrter Tenant liefert 404 (hier: +// ErrNotFound) fuer seine Verzeichnisse, auch wenn die Zuordnung selbst +// weiterhin existiert. +func TestResolve_SuspendedTenantIsInvalid(t *testing.T) { + f := setup(t) + ctx := context.Background() + insertTenant(t, f.pool, "acme") + + if _, err := f.store.Add(ctx, f.admin, "acme", "/data/acme/consume", PurposeConsume); err != nil { + t.Fatalf("add: %v", err) + } + if _, err := f.store.Resolve(ctx, "/data/acme/consume"); err != nil { + t.Fatalf("vor sperrung sollte resolve funktionieren: %v", err) + } + + if _, err := f.registry.Suspend(ctx, "acme"); err != nil { + t.Fatalf("suspend: %v", err) + } + + if _, err := f.store.Resolve(ctx, "/data/acme/consume"); !errors.Is(err, ErrNotFound) { + t.Fatalf("gesperrter tenant: erwartet ErrNotFound, habe %v", err) + } + + // ListForTenant bleibt unveraendert nutzbar (Verwaltungssicht, keine + // Aktivitaetspruefung) -- die Zuordnung existiert weiterhin. + list, err := f.store.ListForTenant(ctx, "acme") + if err != nil || len(list) != 1 { + t.Fatalf("listforTenant nach sperrung: %+v, %v", list, err) + } +} + +func TestNormalizePath_RejectsInvalid(t *testing.T) { + f := setup(t) + ctx := context.Background() + insertTenant(t, f.pool, "acme") + + for _, p := range []string{"", "relative/pfad", "/", "."} { + if _, err := f.store.Add(ctx, f.admin, "acme", p, PurposeConsume); !errors.Is(err, ErrInvalidPath) { + t.Errorf("pfad %q: erwartet ErrInvalidPath, habe %v", p, err) + } + } + // ".." wird von filepath.Clean lexikalisch aufgeloest, nicht als + // ungueltig verworfen (kein Sicherheitsproblem: das Ergebnis ist ein + // gewoehnlicher, bereinigter absoluter Pfad) — hier nur belegt, dass + // dabei kein Traversal ueber die Wurzel hinaus entsteht. + if _, err := f.store.Add(ctx, f.admin, "acme", "/a/../../etc", PurposeConsume); err != nil { + t.Fatalf("bereinigter pfad haette angelegt werden sollen: %v", err) + } + got, err := f.store.Resolve(ctx, "/etc") + if err != nil || got.Path != "/etc" { + t.Fatalf("erwartet aufgeloesten pfad /etc, habe %+v, %v", got, err) + } + if _, err := f.store.Add(ctx, f.admin, "acme", "/valide/pfad", "unsinn"); !errors.Is(err, ErrInvalidPurpose) { + t.Fatalf("ungueltiger zweck: erwartet ErrInvalidPurpose, habe %v", err) + } +} + +func TestAdd_UnknownTenant(t *testing.T) { + f := setup(t) + ctx := context.Background() + if _, err := f.store.Add(ctx, f.admin, "unbekannt", "/data/x", PurposeConsume); !errors.Is(err, ErrTenantNotFound) { + t.Fatalf("erwartet ErrTenantNotFound, habe %v", err) + } +} diff --git a/internal/tenantimportdirapi/tenantimportdirapi.go b/internal/tenantimportdirapi/tenantimportdirapi.go new file mode 100644 index 00000000..bdd5eb81 --- /dev/null +++ b/internal/tenantimportdirapi/tenantimportdirapi.go @@ -0,0 +1,131 @@ +// Package tenantimportdirapi bindet TEN-12 (internal/tenantimportdir) als +// HTTP-Schnittstelle ein: Verwaltung (Add/Remove, nur Superadmin, ueber die +// Tenant-Admin-API) und Abfrage (List/Resolve, fuer Module mit dem +// bestehenden Core-Dienst-Token aus TEN-01/cmd/core) getrennt, wie im +// Ticket "Technische Grundlage" gefordert. +package tenantimportdirapi + +import ( + "encoding/json" + "errors" + "net/http" + + "gitea.perlbach24.de/scripte/nexarch/internal/apierror" + "gitea.perlbach24.de/scripte/nexarch/internal/tenantimportdir" +) + +type Handler struct { + store *tenantimportdir.Store +} + +func NewHandler(store *tenantimportdir.Store) *Handler { + return &Handler{store: store} +} + +func writeErr(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, tenantimportdir.ErrNotSuperadmin): + apierror.Write(w, http.StatusForbidden, apierror.CodeForStatus(http.StatusForbidden), err.Error()) + case errors.Is(err, tenantimportdir.ErrTenantNotFound), errors.Is(err, tenantimportdir.ErrNotFound): + apierror.Write(w, http.StatusNotFound, apierror.CodeForStatus(http.StatusNotFound), err.Error()) + case errors.Is(err, tenantimportdir.ErrPathTaken), errors.Is(err, tenantimportdir.ErrNestedPath), + errors.Is(err, tenantimportdir.ErrInvalidPath), errors.Is(err, tenantimportdir.ErrInvalidPurpose): + apierror.Write(w, http.StatusConflict, apierror.CodeForStatus(http.StatusConflict), err.Error()) + default: + apierror.Write(w, http.StatusInternalServerError, apierror.CodeForStatus(http.StatusInternalServerError), err.Error()) + } +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +// --- Verwaltung (Tenant-Admin-API, Superadmin per Query-/Body-Parameter, +// gleiches Muster wie internal/tenantadmin: Session-/Token-Authentifizierung +// ist IAM-01/IAM-02 und wird hier nicht dupliziert; svcauth schuetzt den +// Transport, requireSuperadmin in tenantimportdir.Store die Aktion.) --- + +type addRequest struct { + Superadmin string `json:"superadmin"` + Path string `json:"path"` + Purpose string `json:"purpose"` +} + +// AddHandler bindet POST /tenants/{slug}/import-directories. +func (h *Handler) AddHandler(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + var req addRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + apierror.Write(w, http.StatusBadRequest, apierror.CodeForStatus(http.StatusBadRequest), "ungueltiger anfrage-koerper") + return + } + d, err := h.store.Add(r.Context(), req.Superadmin, slug, req.Path, tenantimportdir.Purpose(req.Purpose)) + if err != nil { + writeErr(w, err) + return + } + writeJSON(w, http.StatusCreated, directoryJSON(d)) +} + +// RemoveHandler bindet DELETE /tenants/{slug}/import-directories?path=...&superadmin=.... +func (h *Handler) RemoveHandler(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + q := r.URL.Query() + if err := h.store.Remove(r.Context(), q.Get("superadmin"), slug, q.Get("path")); err != nil { + writeErr(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --- Abfrage (Module mit Core-Dienst-Token) --- + +// ListForTenantHandler bindet GET /tenants/{slug}/import-directories. +func (h *Handler) ListForTenantHandler(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + list, err := h.store.ListForTenant(r.Context(), slug) + if err != nil { + writeErr(w, err) + return + } + out := make([]directoryDTO, 0, len(list)) + for _, d := range list { + out = append(out, directoryJSON(d)) + } + writeJSON(w, http.StatusOK, out) +} + +// ResolveHandler bindet GET /import-directories/resolve?path=.... +func (h *Handler) ResolveHandler(w http.ResponseWriter, r *http.Request) { + path := r.URL.Query().Get("path") + if path == "" { + apierror.Write(w, http.StatusBadRequest, apierror.CodeForStatus(http.StatusBadRequest), "path ist pflicht") + return + } + d, err := h.store.Resolve(r.Context(), path) + if err != nil { + writeErr(w, err) + return + } + writeJSON(w, http.StatusOK, directoryJSON(d)) +} + +type directoryDTO struct { + ID string `json:"id"` + TenantSlug string `json:"tenant_slug"` + Path string `json:"path"` + Purpose string `json:"purpose"` + CreatedAt string `json:"created_at"` +} + +func directoryJSON(d tenantimportdir.Directory) directoryDTO { + return directoryDTO{ + ID: d.ID, + TenantSlug: d.TenantSlug, + Path: d.Path, + Purpose: string(d.Purpose), + CreatedAt: d.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } +} diff --git a/migrations/0017_tenant_import_directories.down.sql b/migrations/0017_tenant_import_directories.down.sql new file mode 100644 index 00000000..fe97a2ce --- /dev/null +++ b/migrations/0017_tenant_import_directories.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS tenant_import_directories; diff --git a/migrations/0017_tenant_import_directories.up.sql b/migrations/0017_tenant_import_directories.up.sql new file mode 100644 index 00000000..9d289f10 --- /dev/null +++ b/migrations/0017_tenant_import_directories.up.sql @@ -0,0 +1,16 @@ +-- TEN-12: zentrale Zuordnung Import-Verzeichnis -> Tenant. Ersetzt die +-- lokale Registry-Datei, die das DMS (INT-14) bis dahin fuehrt +-- (dms/docs/BETRIEB-IMPORT-VERZEICHNISSE.md). Ein Pfad gehoert global +-- hoechstens einem Tenant (UNIQUE); die Verschachtelungspruefung ueber +-- Tenants hinweg erfolgt in internal/tenantimportdir (Anwendungscode), da +-- Praefix-Vergleiche in SQL ohne Erweiterung nicht sinnvoll als Constraint +-- abbildbar sind. +CREATE TABLE tenant_import_directories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + path TEXT NOT NULL UNIQUE, + purpose TEXT NOT NULL CHECK (purpose IN ('consume', 'scanner')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_tenant_import_directories_tenant ON tenant_import_directories(tenant_id);