Merge origin/feature/ten-12-import-verzeichnisse-je-tenant-in-der-registry-fuehren
This commit is contained in:
commit
42e12e188b
10 files changed
+890
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS tenant_import_directories;
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user