Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad00c6258d | ||
|
|
2e9ec88b81 | ||
|
|
51b639625f | ||
|
|
f8905e9c64 | ||
|
|
184a219c8a | ||
|
|
118a524cec | ||
|
|
ab826dac3c | ||
|
|
d5acbe1029 | ||
|
|
6b42de39b9 | ||
|
|
fd4a0568a3 | ||
|
|
65cc0ac7bf | ||
|
|
bc47c1331b | ||
|
|
2518160c92 |
No files matched your search
@@ -22,10 +22,11 @@ jobs:
|
||||
go build ./...
|
||||
go vet ./...
|
||||
go test ./... -count=1
|
||||
- name: Naechsten Patch-Tag setzen
|
||||
- name: Naechsten Tag setzen
|
||||
run: |
|
||||
last=$(git tag -l 'common/v0.*' --sort=-v:refname | head -n1)
|
||||
if [ -z "$last" ]; then next="common/v0.1.0"; else
|
||||
minor=$(tr -d '[:space:]' < common/MINOR)
|
||||
last=$(git tag -l "common/v0.${minor}.*" --sort=-v:refname | head -n1)
|
||||
if [ -z "$last" ]; then next="common/v0.${minor}.0"; else
|
||||
patch=${last##*.}; base=${last%.*}; next="${base}.$((patch + 1))"
|
||||
fi
|
||||
git tag "$next"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
2
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
Geteiltes Go-Modul für Pakete, die in mehreren NEXARCH-Modulen identisch gebraucht werden (`internal/` ist modulübergreifend nicht sichtbar).
|
||||
|
||||
Pakete: `pflichttestgate` (parametrisierte Testpflicht-Logik), `apierror`, `auditclient`, `httpclient`, `httpharden`, `loadtest`, `migrate`, `policyclient`, `svcauth`, `tenantregistryclient`, `testdb`.
|
||||
Pakete: `pflichttestgate` (parametrisierte Testpflicht-Logik), `apierror`, `auditclient`, `httpclient`, `httpharden`, `loadtest`, `migrate`, `moduleauth`, `policyclient`, `svcauth`, `tenantregistryclient`, `testdb`.
|
||||
|
||||
## Einbinden
|
||||
|
||||
@@ -22,5 +22,5 @@ Tags `common/v0.X.Y` setzt `.gitea/workflows/common-tag.yml` nach jedem Merge in
|
||||
- Nur Code, der in mindestens zwei Modulen byte-gleich (ohne Kommentare) gebraucht wird; fachlich verschiedene Pakete bleiben modul-lokal.
|
||||
- Änderungen abwärtskompatibel halten, solange Module nicht gleichzeitig umgestellt werden.
|
||||
|
||||
- Jeder PR an `common/` nennt die betroffenen Module (Auswirkungsliste). Breaking Changes nur mit neuer Minor-Version (`v0.2.0`) und Umstellungs-PRs je Modul.
|
||||
- Jeder PR an `common/` nennt die betroffenen Module (Auswirkungsliste). Breaking Changes nur mit neuer Minor-Version (`v0.2.0`) und Umstellungs-PRs je Modul; die aktuelle Minor steht in `common/MINOR`, der Tag-Workflow setzt bei einer neuen Minor `v0.<minor>.0`.
|
||||
- Sicherheitskritische Pakete (`svcauth`, `tenantregistryclient`, `auditclient`, `policyclient`, `apierror`, `httpharden`) unterliegen dem Pflichttest-Gate (`cmd/pflichttestgate`, Workflow `common-pflichttest-gate.yml`): Änderung ohne begleitende `_test.go` im selben Package schlägt fehl.
|
||||
+54
-28
@@ -1,6 +1,7 @@
|
||||
// Package migrate implements versioned, revertible SQL migrations (no ORM) — a
|
||||
// standalone copy of the Core/DMS pattern, since Connect is its own Go module
|
||||
// and cannot import other modules' internal/ packages.
|
||||
// Package migrate implements versioned, revertible SQL migrations (no ORM).
|
||||
// Jedes Modul benennt seine Fortschrittstabelle selbst (New), damit sich
|
||||
// Module in derselben Datenbank nicht gegenseitig den Fortschritt
|
||||
// ueberschreiben (FDN-13).
|
||||
package migrate
|
||||
|
||||
import (
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -52,47 +54,71 @@ func Load(dir string) ([]Migration, error) {
|
||||
return migrations, nil
|
||||
}
|
||||
|
||||
// TrackingTable ist modulspezifisch benannt, damit sich Module in
|
||||
// derselben Datenbank/demselben Schema nicht gegenseitig den
|
||||
// Migrationsfortschritt ueberschreiben (FDN-13).
|
||||
const TrackingTable = "connect_schema_migrations"
|
||||
var tableNamePattern = regexp.MustCompile(`^[a-z_][a-z0-9_]{0,62}$`)
|
||||
|
||||
// Migrator fuehrt Migrationen gegen eine modulspezifisch benannte Tabelle aus.
|
||||
type Migrator struct {
|
||||
table string
|
||||
}
|
||||
|
||||
// New liefert einen Migrator fuer die Fortschrittstabelle table (z. B.
|
||||
// "dms_schema_migrations"). Der Name geht unquotiert in SQL und muss daher
|
||||
// ^[a-z_][a-z0-9_]{0,62}$ entsprechen.
|
||||
func New(table string) (*Migrator, error) {
|
||||
if !tableNamePattern.MatchString(table) {
|
||||
return nil, fmt.Errorf("migrate: ungueltiger tabellenname %q", table)
|
||||
}
|
||||
return &Migrator{table: table}, nil
|
||||
}
|
||||
|
||||
// MustNew wie New, panikt bei ungueltigem Namen (fuer Paketvariablen).
|
||||
func MustNew(table string) *Migrator {
|
||||
mg, err := New(table)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return mg
|
||||
}
|
||||
|
||||
// Table liefert den Namen der Fortschrittstabelle.
|
||||
func (mg *Migrator) Table() string { return mg.table }
|
||||
|
||||
const legacyTrackingTable = "schema_migrations"
|
||||
|
||||
// ensureTrackingTable legt TrackingTable an. Beim erstmaligen Anlegen wird
|
||||
// ensureTrackingTable legt mg.table an. Beim erstmaligen Anlegen wird
|
||||
// ein vorhandener Bestand aus der frueheren, unqualifizierten
|
||||
// schema_migrations uebernommen (nur Versionen, die dieses Modul kennt, und
|
||||
// nur wenn dessen Basisversion dort steht — eine schema_migrations eines
|
||||
// anderen Moduls oder eines anderen Schemas wird weder gelesen noch
|
||||
// veraendert). Die alte Tabelle
|
||||
// bleibt unangetastet.
|
||||
func ensureTrackingTable(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) error {
|
||||
func (mg *Migrator) ensureTrackingTable(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) error {
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s anlegen: %w", TrackingTable, err)
|
||||
return fmt.Errorf("%s anlegen: %w", mg.table, err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
// Serialisiert gleichzeitige Erstanlage/Uebernahme (mehrere App-Instanzen).
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('connect_schema_migrations'))`); err != nil {
|
||||
return fmt.Errorf("%s anlegen: %w", TrackingTable, err)
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext($1))`, mg.table); err != nil {
|
||||
return fmt.Errorf("%s anlegen: %w", mg.table, err)
|
||||
}
|
||||
|
||||
var existed bool
|
||||
if err := tx.QueryRow(ctx, `SELECT to_regclass(current_schema() || '.' || $1::text) IS NOT NULL`, TrackingTable).Scan(&existed); err != nil {
|
||||
return fmt.Errorf("%s anlegen: %w", TrackingTable, err)
|
||||
if err := tx.QueryRow(ctx, `SELECT to_regclass(current_schema() || '.' || $1::text) IS NOT NULL`, mg.table).Scan(&existed); err != nil {
|
||||
return fmt.Errorf("%s anlegen: %w", mg.table, err)
|
||||
}
|
||||
if existed {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
CREATE TABLE `+TrackingTable+` (
|
||||
CREATE TABLE `+mg.table+` (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("%s anlegen: %w", TrackingTable, err)
|
||||
return fmt.Errorf("%s anlegen: %w", mg.table, err)
|
||||
}
|
||||
|
||||
if len(migrations) > 0 {
|
||||
@@ -113,7 +139,7 @@ func ensureTrackingTable(ctx context.Context, pool *pgxpool.Pool, migrations []M
|
||||
}
|
||||
if hasBase {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO `+TrackingTable+` (version, applied_at)
|
||||
INSERT INTO `+mg.table+` (version, applied_at)
|
||||
SELECT version, applied_at FROM `+legacyTrackingTable+` WHERE version = ANY($1)
|
||||
`, versions); err != nil {
|
||||
return fmt.Errorf("bestand aus %s uebernehmen: %w", legacyTrackingTable, err)
|
||||
@@ -124,8 +150,8 @@ func ensureTrackingTable(ctx context.Context, pool *pgxpool.Pool, migrations []M
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func appliedVersions(ctx context.Context, pool *pgxpool.Pool) (map[string]bool, error) {
|
||||
rows, err := pool.Query(ctx, `SELECT version FROM `+TrackingTable)
|
||||
func (mg *Migrator) appliedVersions(ctx context.Context, pool *pgxpool.Pool) (map[string]bool, error) {
|
||||
rows, err := pool.Query(ctx, `SELECT version FROM `+mg.table)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("angewendete migrationen lesen: %w", err)
|
||||
}
|
||||
@@ -146,11 +172,11 @@ func appliedVersions(ctx context.Context, pool *pgxpool.Pool) (map[string]bool,
|
||||
// (Akzeptanzkriterium 2: vorwaerts ausfuehrbar) — bereits angewendete
|
||||
// werden uebersprungen, damit Up auf einer leeren UND auf einer bestehenden
|
||||
// DB funktioniert (Pruefung 1).
|
||||
func Up(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (applied []string, err error) {
|
||||
if err := ensureTrackingTable(ctx, pool, migrations); err != nil {
|
||||
func (mg *Migrator) Up(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (applied []string, err error) {
|
||||
if err := mg.ensureTrackingTable(ctx, pool, migrations); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
already, err := appliedVersions(ctx, pool)
|
||||
already, err := mg.appliedVersions(ctx, pool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -167,7 +193,7 @@ func Up(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (applie
|
||||
_ = tx.Rollback(ctx)
|
||||
return applied, fmt.Errorf("migration %q anwenden: %w", m.Version, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO `+TrackingTable+` (version) VALUES ($1)`, m.Version); err != nil {
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO `+mg.table+` (version) VALUES ($1)`, m.Version); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return applied, fmt.Errorf("migration %q als angewendet markieren: %w", m.Version, err)
|
||||
}
|
||||
@@ -182,11 +208,11 @@ func Up(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (applie
|
||||
// DownOne macht die zuletzt angewendete Migration rueckgaengig
|
||||
// (Akzeptanzkriterium 2: rueckwaerts ausfuehrbar) und liefert deren Version,
|
||||
// oder "" falls keine Migration angewendet war.
|
||||
func DownOne(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (version string, err error) {
|
||||
if err := ensureTrackingTable(ctx, pool, migrations); err != nil {
|
||||
func (mg *Migrator) DownOne(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (version string, err error) {
|
||||
if err := mg.ensureTrackingTable(ctx, pool, migrations); err != nil {
|
||||
return "", err
|
||||
}
|
||||
already, err := appliedVersions(ctx, pool)
|
||||
already, err := mg.appliedVersions(ctx, pool)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -210,9 +236,9 @@ func DownOne(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (v
|
||||
_ = tx.Rollback(ctx)
|
||||
return "", fmt.Errorf("migration %q zurueckrollen: %w", last.Version, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM `+TrackingTable+` WHERE version = $1`, last.Version); err != nil {
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM `+mg.table+` WHERE version = $1`, last.Version); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return "", fmt.Errorf("migration %q aus %s entfernen: %w", last.Version, TrackingTable, err)
|
||||
return "", fmt.Errorf("migration %q aus %s entfernen: %w", last.Version, mg.table, err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return "", fmt.Errorf("rollback von %q committen: %w", last.Version, err)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
)
|
||||
|
||||
func TestNewValidatesTableName(t *testing.T) {
|
||||
for _, ok := range []string{"dms_schema_migrations", "_x", "a1"} {
|
||||
if _, err := New(ok); err != nil {
|
||||
t.Errorf("New(%q): %v", ok, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "1abc", "Dms", "a-b", "a;drop table x", "a b", "schema.table"} {
|
||||
if _, err := New(bad); err == nil {
|
||||
t.Errorf("New(%q) sollte scheitern", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeMigrations(t *testing.T, files map[string]string) []Migration {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for name, body := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
migs, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return migs
|
||||
}
|
||||
|
||||
func TestUpDownAndSeparateTrackingTables(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pool := testdb.Pool(t, testdb.AdminDSN(t))
|
||||
migs := writeMigrations(t, map[string]string{
|
||||
"0001_a.up.sql": "CREATE TABLE mig_a (id int)",
|
||||
"0001_a.down.sql": "DROP TABLE mig_a",
|
||||
"0002_b.up.sql": "CREATE TABLE mig_b (id int)",
|
||||
"0002_b.down.sql": "DROP TABLE mig_b",
|
||||
})
|
||||
one := MustNew("one_schema_migrations")
|
||||
two := MustNew("two_schema_migrations")
|
||||
|
||||
applied, err := one.Up(ctx, pool, migs)
|
||||
if err != nil || len(applied) != 2 {
|
||||
t.Fatalf("Up: applied=%v err=%v", applied, err)
|
||||
}
|
||||
again, err := one.Up(ctx, pool, migs)
|
||||
if err != nil || len(again) != 0 {
|
||||
t.Fatalf("zweites Up soll nichts anwenden: applied=%v err=%v", again, err)
|
||||
}
|
||||
|
||||
var n int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM two_schema_migrations`).Scan(&n); err == nil {
|
||||
t.Fatal("two_schema_migrations darf vor dem ersten Lauf von two nicht existieren")
|
||||
}
|
||||
if _, err := two.DownOne(ctx, pool, migs); err != nil {
|
||||
t.Fatalf("two.DownOne: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM one_schema_migrations`).Scan(&n); err != nil || n != 2 {
|
||||
t.Fatalf("Fortschritt von one muss unberuehrt sein: n=%d err=%v", n, err)
|
||||
}
|
||||
|
||||
v, err := one.DownOne(ctx, pool, migs)
|
||||
if err != nil || v != "0002_b" {
|
||||
t.Fatalf("DownOne: v=%q err=%v", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyTrackingTableIsAdopted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pool := testdb.Pool(t, testdb.AdminDSN(t))
|
||||
migs := writeMigrations(t, map[string]string{
|
||||
"0001_a.up.sql": "CREATE TABLE mig_leg (id int)",
|
||||
"0001_a.down.sql": "DROP TABLE mig_leg",
|
||||
})
|
||||
for _, q := range []string{
|
||||
`CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
|
||||
`INSERT INTO schema_migrations (version) VALUES ('0001_a')`,
|
||||
`CREATE TABLE mig_leg (id int)`,
|
||||
} {
|
||||
if _, err := pool.Exec(ctx, q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
applied, err := MustNew("leg_schema_migrations").Up(ctx, pool, migs)
|
||||
if err != nil || len(applied) != 0 {
|
||||
t.Fatalf("uebernommene Version darf nicht erneut laufen: applied=%v err=%v", applied, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package moduleauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Identity ist die aus einem gueltigen Modul-Token gewonnene Identitaet.
|
||||
// Der Mandant kommt ausschliesslich hieraus, nie aus einem Anfrageparameter
|
||||
// (INT-18, Befund F-20: Mail-API las den Mandanten bisher aus "?tenant=").
|
||||
type Identity struct {
|
||||
UserID string
|
||||
Tenant string
|
||||
Role string
|
||||
}
|
||||
|
||||
type identityKey struct{}
|
||||
|
||||
func WithIdentity(ctx context.Context, id Identity) context.Context {
|
||||
return context.WithValue(ctx, identityKey{}, id)
|
||||
}
|
||||
|
||||
func IdentityFrom(ctx context.Context) (Identity, bool) {
|
||||
id, ok := ctx.Value(identityKey{}).(Identity)
|
||||
return id, ok
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(errorResponse{Error: message})
|
||||
}
|
||||
|
||||
// RequireAuth verlangt ein gueltiges Modul-Token (Bearer) fuer next. Es gibt
|
||||
// hier bewusst keinen "caller"-Uebergangsmodus wie im DMS: die Mail-API war
|
||||
// bislang nirgends verdrahtet (kein cmd/-Aufrufer), es gibt also keinen
|
||||
// bestehenden Client, dessen Kompatibilitaet zu wahren waere.
|
||||
func RequireAuth(v *Verifier, next http.Handler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := bearer(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "nicht angemeldet")
|
||||
return
|
||||
}
|
||||
if v == nil {
|
||||
writeError(w, http.StatusUnauthorized, "nicht angemeldet")
|
||||
return
|
||||
}
|
||||
claims, err := v.Verify(r.Context(), token)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidToken) {
|
||||
writeError(w, http.StatusUnauthorized, "nicht angemeldet")
|
||||
} else {
|
||||
writeError(w, http.StatusServiceUnavailable, "identitaetsdienst nicht erreichbar")
|
||||
}
|
||||
return
|
||||
}
|
||||
id := Identity{UserID: claims.Subject, Tenant: claims.Tenant, Role: claims.Role}
|
||||
next.ServeHTTP(w, r.WithContext(WithIdentity(r.Context(), id)))
|
||||
}
|
||||
}
|
||||
|
||||
func bearer(r *http.Request) (string, bool) {
|
||||
h := r.Header.Get("Authorization")
|
||||
if len(h) > 7 && strings.EqualFold(h[:7], "bearer ") {
|
||||
return strings.TrimSpace(h[7:]), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package moduleauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIssuer signiert Modul-Tokens mit einem einmalig erzeugten Testschluessel
|
||||
// (nur fuer Tests anderer Pakete gedacht, siehe INT-18: mailapi/mailrulesapi
|
||||
// koennen ErrInvalidToken/echte Signaturpruefung nicht ohne einen Aussteller
|
||||
// testen, ein echter Kontodienst ist in Unit-Tests nicht verfuegbar).
|
||||
type TestIssuer struct {
|
||||
kid string
|
||||
pub ed25519.PublicKey
|
||||
prv ed25519.PrivateKey
|
||||
}
|
||||
|
||||
// NewTestVerifierAndIssuer erzeugt ein Ed25519-Testschluesselpaar, einen
|
||||
// Verifier, der ausschliesslich diesen Schluessel kennt, und den passenden
|
||||
// Aussteller. audience/issuer leer = keine Bindung geprueft (Verhalten wie
|
||||
// im Uebergang vor API-18/API-15 verdrahtet sind).
|
||||
func NewTestVerifierAndIssuer() (*Verifier, *TestIssuer) {
|
||||
pub, prv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
iss := &TestIssuer{kid: "test-key", pub: pub, prv: prv}
|
||||
v := NewVerifier(time.Minute, "", func(context.Context) (map[string]ed25519.PublicKey, error) {
|
||||
return map[string]ed25519.PublicKey{iss.kid: iss.pub}, nil
|
||||
})
|
||||
return v, iss
|
||||
}
|
||||
|
||||
func b64json(v any) string {
|
||||
raw, _ := json.Marshal(v)
|
||||
return base64.RawURLEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
// Token stellt ein gueltiges Modul-Token fuer subject/tenant/role aus,
|
||||
// optional mit audience/issuer (leer = Claim wird weggelassen).
|
||||
func (t *TestIssuer) Token(subject, tenant, role, audience, issuer string) string {
|
||||
return t.TokenExpiringAt(subject, tenant, role, audience, issuer, time.Now().Add(time.Hour))
|
||||
}
|
||||
|
||||
// TokenExpiringAt wie Token, aber mit fest vorgegebenem Ablaufzeitpunkt
|
||||
// (fuer Tests, die ein bereits abgelaufenes Token brauchen).
|
||||
func (t *TestIssuer) TokenExpiringAt(subject, tenant, role, audience, issuer string, exp time.Time) string {
|
||||
now := time.Now()
|
||||
claims := map[string]any{
|
||||
"sub": subject, "tenant": tenant, "role": role,
|
||||
"iat": now.Unix(), "exp": exp.Unix(),
|
||||
}
|
||||
if audience != "" {
|
||||
claims["aud"] = audience
|
||||
}
|
||||
if issuer != "" {
|
||||
claims["iss"] = issuer
|
||||
}
|
||||
h := b64json(map[string]string{"alg": "EdDSA", "kid": t.kid, "typ": "JWT"})
|
||||
signing := h + "." + b64json(claims)
|
||||
return signing + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(t.prv, []byte(signing)))
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Package moduleauth verifiziert Core-Modul-JWTs (API-05: EdDSA, kid, JWKS) lokal.
|
||||
package moduleauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInvalidToken = errors.New("moduleauth: ungueltiges token")
|
||||
|
||||
const (
|
||||
leeway = 30 * time.Second
|
||||
minRefreshInterval = 30 * time.Second
|
||||
maxJWKSBytes = 1 << 20
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
Subject string
|
||||
Tenant string
|
||||
Role string
|
||||
}
|
||||
|
||||
type KeyFetchFunc func(ctx context.Context) (map[string]ed25519.PublicKey, error)
|
||||
|
||||
type Verifier struct {
|
||||
fetch KeyFetchFunc
|
||||
ttl time.Duration
|
||||
tenant string
|
||||
now func() time.Time
|
||||
|
||||
audience string
|
||||
issuer string
|
||||
enforceAudience bool
|
||||
|
||||
mu sync.Mutex
|
||||
keys map[string]ed25519.PublicKey
|
||||
fetchedAt time.Time
|
||||
lastAttempt time.Time
|
||||
}
|
||||
|
||||
func NewVerifier(ttl time.Duration, tenant string, fetch KeyFetchFunc) *Verifier {
|
||||
return &Verifier{fetch: fetch, ttl: ttl, tenant: tenant, now: time.Now}
|
||||
}
|
||||
|
||||
// Expect bindet das Modul an ein Zielmodul ("aud") und einen Aussteller ("iss")
|
||||
// (QA-15 F-07). Ist enforce gesetzt, weist der Verifier Tokens ohne passende
|
||||
// "aud" ab; ohne enforce (Uebergang beim Ausrollen) werden Tokens ohne "aud"
|
||||
// noch angenommen, ein Token mit fremder "aud" wird aber immer abgewiesen.
|
||||
// "iss" wird geprueft, sobald ein Aussteller konfiguriert ist und das Token
|
||||
// einen traegt bzw. enforce gilt.
|
||||
func (v *Verifier) Expect(audience, issuer string, enforce bool) *Verifier {
|
||||
v.audience, v.issuer, v.enforceAudience = audience, issuer, enforce
|
||||
return v
|
||||
}
|
||||
|
||||
// keySet liefert die Schluessel: frisch aus dem Cache, sonst per Abruf; schlaegt
|
||||
// der Abruf fehl, gilt der letzte bekannte Satz weiter (Core-Ausfall), ohne
|
||||
// Satz ist die Verifikation nicht moeglich. force erzwingt einen Abruf (unbekannte
|
||||
// kid nach Rotation), hoechstens einmal je minRefreshInterval.
|
||||
func (v *Verifier) keySet(ctx context.Context, force bool) (map[string]ed25519.PublicKey, error) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
now := v.now()
|
||||
fresh := v.keys != nil && now.Sub(v.fetchedAt) < v.ttl
|
||||
if fresh && !force {
|
||||
return v.keys, nil
|
||||
}
|
||||
if force && now.Sub(v.lastAttempt) < minRefreshInterval {
|
||||
if v.keys != nil {
|
||||
return v.keys, nil
|
||||
}
|
||||
}
|
||||
v.lastAttempt = now
|
||||
keys, err := v.fetch(ctx)
|
||||
if err == nil {
|
||||
v.keys, v.fetchedAt = keys, now
|
||||
return keys, nil
|
||||
}
|
||||
if v.keys != nil {
|
||||
return v.keys, nil
|
||||
}
|
||||
return nil, fmt.Errorf("moduleauth: schluesselsatz nicht verfuegbar: %w", err)
|
||||
}
|
||||
|
||||
type header struct {
|
||||
Alg string `json:"alg"`
|
||||
Kid string `json:"kid"`
|
||||
}
|
||||
|
||||
type payload struct {
|
||||
Sub string `json:"sub"`
|
||||
Tenant string `json:"tenant"`
|
||||
Role string `json:"role"`
|
||||
Exp float64 `json:"exp"`
|
||||
Iat float64 `json:"iat"`
|
||||
Iss string `json:"iss"`
|
||||
Aud json.RawMessage `json:"aud"`
|
||||
}
|
||||
|
||||
// audiences liest "aud" als String oder Liste (RFC 7519 erlaubt beides).
|
||||
func (p payload) audiences() ([]string, bool) {
|
||||
if len(p.Aud) == 0 || string(p.Aud) == "null" {
|
||||
return nil, true
|
||||
}
|
||||
var one string
|
||||
if err := json.Unmarshal(p.Aud, &one); err == nil {
|
||||
return []string{one}, true
|
||||
}
|
||||
var many []string
|
||||
if err := json.Unmarshal(p.Aud, &many); err == nil {
|
||||
return many, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (v *Verifier) checkScope(p payload) bool {
|
||||
auds, ok := p.audiences()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if v.audience != "" {
|
||||
if len(auds) == 0 {
|
||||
if v.enforceAudience {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
match := false
|
||||
for _, a := range auds {
|
||||
if a == v.audience {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if v.issuer != "" && (p.Iss != "" || v.enforceAudience) && p.Iss != v.issuer {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (v *Verifier) Verify(ctx context.Context, token string) (Claims, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return Claims{}, ErrInvalidToken
|
||||
}
|
||||
var h header
|
||||
if err := decodePart(parts[0], &h); err != nil || h.Alg != "EdDSA" || h.Kid == "" {
|
||||
return Claims{}, ErrInvalidToken
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil || len(sig) != ed25519.SignatureSize {
|
||||
return Claims{}, ErrInvalidToken
|
||||
}
|
||||
|
||||
keys, err := v.keySet(ctx, false)
|
||||
if err != nil {
|
||||
return Claims{}, err
|
||||
}
|
||||
pub, ok := keys[h.Kid]
|
||||
if !ok {
|
||||
if keys, err = v.keySet(ctx, true); err != nil {
|
||||
return Claims{}, err
|
||||
}
|
||||
if pub, ok = keys[h.Kid]; !ok {
|
||||
return Claims{}, ErrInvalidToken
|
||||
}
|
||||
}
|
||||
if !ed25519.Verify(pub, []byte(parts[0]+"."+parts[1]), sig) {
|
||||
return Claims{}, ErrInvalidToken
|
||||
}
|
||||
|
||||
var p payload
|
||||
if err := decodePart(parts[1], &p); err != nil || p.Sub == "" || p.Exp == 0 {
|
||||
return Claims{}, ErrInvalidToken
|
||||
}
|
||||
now := v.now()
|
||||
if now.After(time.Unix(int64(p.Exp), 0).Add(leeway)) {
|
||||
return Claims{}, ErrInvalidToken
|
||||
}
|
||||
if p.Iat != 0 && time.Unix(int64(p.Iat), 0).After(now.Add(leeway)) {
|
||||
return Claims{}, ErrInvalidToken
|
||||
}
|
||||
if v.tenant != "" && p.Tenant != v.tenant {
|
||||
return Claims{}, ErrInvalidToken
|
||||
}
|
||||
if !v.checkScope(p) {
|
||||
return Claims{}, ErrInvalidToken
|
||||
}
|
||||
return Claims{Subject: p.Sub, Tenant: p.Tenant, Role: p.Role}, nil
|
||||
}
|
||||
|
||||
func decodePart(part string, into any) error {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(part)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, into)
|
||||
}
|
||||
|
||||
type jwksResponse struct {
|
||||
Keys []struct {
|
||||
Kid string `json:"kid"`
|
||||
PublicKey string `json:"public_key"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
|
||||
func HTTPKeyFetcher(url string, client *http.Client) KeyFetchFunc {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 5 * time.Second}
|
||||
}
|
||||
return func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("jwks-abruf: status %d", resp.StatusCode)
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxJWKSBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r jwksResponse
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return nil, fmt.Errorf("jwks parsen: %w", err)
|
||||
}
|
||||
out := make(map[string]ed25519.PublicKey, len(r.Keys))
|
||||
for _, k := range r.Keys {
|
||||
b, err := base64.StdEncoding.DecodeString(k.PublicKey)
|
||||
if err != nil || len(b) != ed25519.PublicKeySize || k.Kid == "" {
|
||||
return nil, fmt.Errorf("jwks: schluessel %q ungueltig", k.Kid)
|
||||
}
|
||||
out[k.Kid] = ed25519.PublicKey(b)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("jwks: leerer schluesselsatz")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package moduleauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestVerify_AcceptsValidToken belegt den Grundfall: ein vom Testaussteller
|
||||
// signiertes, gueltiges Token liefert die erwarteten Claims.
|
||||
func TestVerify_AcceptsValidToken(t *testing.T) {
|
||||
v, iss := NewTestVerifierAndIssuer()
|
||||
tok := iss.Token("u1", "acme", "user", "", "")
|
||||
c, err := v.Verify(context.Background(), tok)
|
||||
if err != nil || c.Subject != "u1" || c.Tenant != "acme" || c.Role != "user" {
|
||||
t.Fatalf("claims=%+v err=%v", c, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_RejectsForeignSignature: ein Token eines anderen Schluessels
|
||||
// (der Verifier kennt dessen kid nicht) wird abgewiesen.
|
||||
func TestVerify_RejectsForeignSignature(t *testing.T) {
|
||||
v, _ := NewTestVerifierAndIssuer()
|
||||
_, other := NewTestVerifierAndIssuer()
|
||||
if _, err := v.Verify(context.Background(), other.Token("u1", "acme", "user", "", "")); err == nil {
|
||||
t.Fatal("erwartete fehler bei fremder signatur")
|
||||
}
|
||||
}
|
||||
|
||||
// TestScope_AudienceMismatchRejected ist der Kern von API-18/F-07: mit
|
||||
// Expect(audience="mail", ..., enforce=true) gebundene Verifier weisen ein
|
||||
// Token mit aud="dms" ab, akzeptieren aber aud="mail".
|
||||
func TestScope_AudienceMismatchRejected(t *testing.T) {
|
||||
v, iss := NewTestVerifierAndIssuer()
|
||||
v.Expect("mail", "", true)
|
||||
|
||||
if _, err := v.Verify(context.Background(), iss.Token("u1", "acme", "user", "dms", "")); err == nil {
|
||||
t.Fatal("erwartete fehler bei aud=dms gegen einen mail-gebundenen verifier")
|
||||
}
|
||||
if _, err := v.Verify(context.Background(), iss.Token("u1", "acme", "user", "mail", "")); err != nil {
|
||||
t.Fatalf("erwartete erfolg bei aud=mail, habe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScope_EnforceRejectsTokenWithoutAudience: mit enforce=true wird ein
|
||||
// Token OHNE aud-Claim ebenfalls abgewiesen (kein stiller Rueckfall).
|
||||
func TestScope_EnforceRejectsTokenWithoutAudience(t *testing.T) {
|
||||
v, iss := NewTestVerifierAndIssuer()
|
||||
v.Expect("mail", "", true)
|
||||
if _, err := v.Verify(context.Background(), iss.Token("u1", "acme", "user", "", "")); err == nil {
|
||||
t.Fatal("erwartete fehler bei fehlendem aud unter enforce=true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_RejectsExpiredToken.
|
||||
func TestVerify_RejectsExpiredToken(t *testing.T) {
|
||||
v, iss := NewTestVerifierAndIssuer()
|
||||
expired := iss.TokenExpiringAt("u1", "acme", "user", "", "", time.Now().Add(-2*time.Hour))
|
||||
if _, err := v.Verify(context.Background(), expired); err == nil {
|
||||
t.Fatal("erwartete fehler bei abgelaufenem token")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# Arbeitskarten (Stand 2026-10-27)
|
||||
|
||||
Reihenfolge = Abarbeitungsfolge. Status: offen / blockiert / Entscheidung nötig / erledigt.
|
||||
Regel je Karte: eigener Branch, eigener PR, Tests auf 131 bzw. 204 (nie archivdms), PR-Beschreibung nennt Auswirkungen.
|
||||
|
||||
## A. common/-Modul
|
||||
|
||||
### A-01 Tag common/v0.1.0 — blockiert
|
||||
- Ziel: Tag-Workflow `common-tag.yml` erzeugt `common/v0.1.0`.
|
||||
- Blockiert durch: Runner `nexarch-131-runner` arbeitet CI-Warteschlange ab (Unit `act_runner.service` auf 131).
|
||||
- Schritte: Warteschlange beobachten; bei Lauf-Fehler Log lesen und Workflow korrigieren; Tag per `git ls-remote --tags origin 'common/*'` prüfen.
|
||||
- Fertig, wenn: Tag existiert und `go list -m gitea.perlbach24.de/scripte/nexarch/common@v0.1.0` auf 131 mit GOPRIVATE und `.netrc` funktioniert.
|
||||
- Hinweis: Lesezugriff-Token auf 131 legt der User an; Claude fasst keine Tokens an.
|
||||
|
||||
### A-02 Umstellung archive (Pilot) — wartet auf A-01
|
||||
- Vorlage: Branches `refactor/common-modul-pilot`, `refactor/common-batch-2`.
|
||||
- Schritte: `require .../common v0.1.0` und go.sum; Importe von httpharden, svcauth, auditclient, tenantregistryclient (soweit in archive genutzt) umschreiben; interne Kopien löschen; gofmt wegen Import-Reihenfolge; Pflichttest-Gate von archive um common-Muster erweitern; Modul-Guards wiederherstellen.
|
||||
- Test: `go build/vet/test ./...` im Modul auf 131 in frischem Wurfverzeichnis; DB-Tests auf 204 mit `dms_dev` und `TEST_ADMIN_DSN`.
|
||||
- Nachweis: normalisierter md5-Vergleich (ohne Kommentare/Imports) zeigt, dass gelöschte Kopie und common-Paket gleich waren.
|
||||
- Fertig, wenn: PR offen, Tests grün, Auswirkungsliste in der PR-Beschreibung.
|
||||
|
||||
### A-03 bis A-07 Umstellung weiterer Module — je nach A-02
|
||||
Reihenfolge: dms, mail, ai, connect, workflow; danach Root-Modul.
|
||||
- Pro Modul wie A-02. NICHT nach common: audittrail, cutoverstage, crypto, notifyclient, dms-Varianten von apierror/policyclient/httpharden, Root-httpclient.
|
||||
- Je Modul eigener PR.
|
||||
|
||||
### A-08 CI-DB-Tests ausweiten — Entscheidung nötig
|
||||
- Stand: `testdb.AdminDSN` und `.gitea/workflows/db-tests.yml` (Label `nexarch-test`) sind gemergt, Workflow deckt nur `common/` ab.
|
||||
- Entscheidungen des Users nötig: Runner mit Label `nexarch-test` auf 204 registrieren, Secret `TEST_ADMIN_DSN` (Wegwerf-DB, nie archivdms) setzen.
|
||||
- Schritte danach: Modul-Tests auf `testdb.AdminDSN` umstellen (jeweils mit A-02..A-07), Workflow-Pfade ergänzen.
|
||||
|
||||
### A-09 Reviewer für common/ — Entscheidung nötig
|
||||
- Offene Frage: Gitea-Benutzernamen für CODEOWNERS/Review-Pflicht auf `common/`.
|
||||
|
||||
## B. Portal (Vorschlag Fable, Detail siehe Gesprächsverlauf)
|
||||
|
||||
### B-01 Fundament — erledigt auf feature/portal-ausbau (Entscheidung: Ports bleiben)
|
||||
- Entscheidung: Adressen per Proxy-Pfad `/app/<key>/` oder weiter Ports.
|
||||
- Schritte: `ModuleRegistryEntry` um `group`, `path`, optional `module`; Hrefs relativ, `resolveModuleHref` nur Fallback; Route `GET /api/module-urls` (liest `/.well-known/nexarch-modules`, Env-Override `NEXARCH_MODULE_URL_<KEY>`, Cache); `ModuleGrid` nach `group` gruppieren; `tenantLabel` in `ModuleNavShell` aus Sitzungs-Identität statt Env.
|
||||
- Dateien: `web/shl/components/ModuleNavShell.tsx`, `web/portal/lib/modules.ts`, `web/portal/app/ModuleGrid.tsx`, neu `web/portal/app/api/module-urls/route.ts`.
|
||||
- Test: `web/portal` und `web/shl` vitest auf 131; Betriebsauftrag: Produktionsdatei des Modulverzeichnisses fehlt.
|
||||
|
||||
### B-02 Arbeitsvorrat — erledigt auf feature/portal-ausbau (Entscheidung: X-User-Id als Übergang)
|
||||
- Entscheidung: Übergang mit `X-User-Id` aus verifiziertem Token-`sub`, solange Workflow kein Bearer kennt.
|
||||
- Schritte: Route `GET /api/worklist` (Token prüfen, serverseitig `/approvals`, `/tasks`, `/reminders/kanban`, Antwort nur Zähler); Cache-Schlüssel mit `sub`, 15 s, `Cache-Control: no-store`; Badges an Workflow-Kacheln und "Heute"-Leiste; Fehler je Quelle tolerant; Schnellsuche-Zeile als Deep-Link; Env `PORTAL_WORKFLOW_API_URL`.
|
||||
- Datenschutz: nur eigene Zähler, keine Inhalte.
|
||||
|
||||
### B-03 Admin-/Prüfer-Sicht — erledigt auf feature/portal-ausbau (ohne Favoriten und ohne Ampel an Kacheln, Statusnamen passen nicht sicher zu Registry-Keys)
|
||||
- Route `GET /api/system-status` (nur nach `authorize(core.tenant.admin)`, Status-Endpunkt nur serverseitig); Route `GET /api/kennzahlen-summary` (Mandant und Rolle aus Claims, nur Totals); Ampel an Kacheln; Favoriten (localStorage, nur Modul-Keys) optional.
|
||||
|
||||
### B-04 Umgebungsvariablen dokumentieren — erledigt (web/portal/README.md)
|
||||
- Neue Variablen: `PORTAL_CORE_API_URL`, `PORTAL_TOKEN_ISSUER`, `PORTAL_TOKEN_AUDIENCE`, `PORTAL_VISIBLE_MODULES_CACHE_SECONDS`; dazu `PORTAL_POLICY_API_URL`, `PORTAL_POLICY_SERVICE_TOKEN`. Nirgends dokumentiert; Doku im Portal anlegen.
|
||||
|
||||
### B-05 Zielmodul `portal` im Core — Entscheidung nötig
|
||||
- Der Core stellt Modul-Tokens mit Zielmodul aus (Standard `dms`). Soll `portal` in `NEXARCH_ACCOUNT_MODULE_AUDIENCES` aufgenommen und `PORTAL_TOKEN_AUDIENCE=portal` gesetzt werden? Dann muss die Sitzung `?module=portal` mitschicken.
|
||||
|
||||
### B-06 Workflow auf Bearer umstellen — Folgeticket
|
||||
- Workflow-API von `X-User-Id` auf Bearer (Muster: `mail/internal/moduleauth`); danach Header im Portal-BFF entfernen. Gleiches gilt für Audit-Log in dms (`caller`-Query).
|
||||
|
||||
## C. Laufender Betrieb
|
||||
|
||||
### C-01 Offene PRs mergen (User)
|
||||
- #373 (qa*-Gates, migration*), #374 (workflow contracttest), #375 (dms apierror 5xx), #380 (Docs), #372 (SHL: AccountBar, Portal-Route).
|
||||
- Vor Merge DB-Tests auf 204 für #373, #374, #375 laufen lassen (nie gelaufen).
|
||||
|
||||
### C-02 Aufräumen — nur mit Freigabe
|
||||
- `/root/manticore-backup-pre29` auf 131 löschen, wenn der User es sagt.
|
||||
- 204: Dateileichen im Manticore-Datenverzeichnis (`dms_src13_*`, `mail_documents_reindex_*`, `qa10loadtest_*`) nur nach Sichtung durch den User.
|
||||
@@ -1,13 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { LogoutButton } from "@nexarch/shl";
|
||||
import { AccountBar as SharedAccountBar } from "@nexarch/shl";
|
||||
import { authMode, getSession } from "../lib/http";
|
||||
|
||||
export function AccountBar() {
|
||||
if (authMode() !== "token") return null;
|
||||
return (
|
||||
<header style={{ display: "flex", justifyContent: "flex-end", padding: "8px 16px" }}>
|
||||
<LogoutButton onLogout={() => getSession().logout()} />
|
||||
</header>
|
||||
);
|
||||
// Duenner Wrapper statt kopierter JSX (Fable-Review auf Code-Dopplung):
|
||||
// die eigentliche Darstellung lebt in @nexarch/shl, authMode()/getSession()
|
||||
// sind aber App-spezifisch (eigenes lib/http.ts je App) und duerfen als
|
||||
// Funktionen nicht von layout.tsx (Server Component) ueber die Props-Grenze
|
||||
// an eine Client Component gereicht werden -- deshalb hier lokal aufgeloest.
|
||||
async function loadIdentity() {
|
||||
const s = getSession();
|
||||
await s.token();
|
||||
return s.identity();
|
||||
}
|
||||
|
||||
export function AccountBar() {
|
||||
return <SharedAccountBar visible={authMode() === "token"} onLogout={() => getSession().logout()} loadIdentity={loadIdentity} />;
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { LogoutButton } from "@nexarch/shl";
|
||||
import { AccountBar as SharedAccountBar } from "@nexarch/shl";
|
||||
import { authMode, getSession } from "../lib/http";
|
||||
|
||||
export function AccountBar() {
|
||||
if (authMode() !== "token") return null;
|
||||
return (
|
||||
<header style={{ display: "flex", justifyContent: "flex-end", padding: "8px 16px" }}>
|
||||
<LogoutButton onLogout={() => getSession().logout()} />
|
||||
</header>
|
||||
);
|
||||
// Duenner Wrapper statt kopierter JSX (Fable-Review auf Code-Dopplung):
|
||||
// die eigentliche Darstellung lebt in @nexarch/shl, authMode()/getSession()
|
||||
// sind aber App-spezifisch (eigenes lib/http.ts je App) und duerfen als
|
||||
// Funktionen nicht von layout.tsx (Server Component) ueber die Props-Grenze
|
||||
// an eine Client Component gereicht werden -- deshalb hier lokal aufgeloest.
|
||||
async function loadIdentity() {
|
||||
const s = getSession();
|
||||
await s.token();
|
||||
return s.identity();
|
||||
}
|
||||
|
||||
export function AccountBar() {
|
||||
return <SharedAccountBar visible={authMode() === "token"} onLogout={() => getSession().logout()} loadIdentity={loadIdentity} />;
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { LogoutButton } from "@nexarch/shl";
|
||||
import { AccountBar as SharedAccountBar } from "@nexarch/shl";
|
||||
import { authMode, getSession } from "../lib/http";
|
||||
|
||||
export function AccountBar() {
|
||||
if (authMode() !== "token") return null;
|
||||
return (
|
||||
<header style={{ display: "flex", justifyContent: "flex-end", padding: "8px 16px" }}>
|
||||
<LogoutButton onLogout={() => getSession().logout()} />
|
||||
</header>
|
||||
);
|
||||
// Duenner Wrapper statt kopierter JSX (Fable-Review auf Code-Dopplung):
|
||||
// die eigentliche Darstellung lebt in @nexarch/shl, authMode()/getSession()
|
||||
// sind aber App-spezifisch (eigenes lib/http.ts je App) und duerfen als
|
||||
// Funktionen nicht von layout.tsx (Server Component) ueber die Props-Grenze
|
||||
// an eine Client Component gereicht werden -- deshalb hier lokal aufgeloest.
|
||||
async function loadIdentity() {
|
||||
const s = getSession();
|
||||
await s.token();
|
||||
return s.identity();
|
||||
}
|
||||
|
||||
export function AccountBar() {
|
||||
return <SharedAccountBar visible={authMode() === "token"} onLogout={() => getSession().logout()} loadIdentity={loadIdentity} />;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# NEXARCH Portal
|
||||
|
||||
Startseite nach der Anmeldung: Kacheln der Module, die der angemeldete Benutzer sehen darf.
|
||||
Die Modulliste kommt aus `MODULE_REGISTRY` in `@nexarch/shl`; `/api/visible-modules` filtert sie gegen die policy-api.
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
Browser-seitig (werden beim Build eingebettet):
|
||||
|
||||
| Variable | Pflicht | Bedeutung |
|
||||
|---|---|---|
|
||||
| `NEXT_PUBLIC_CORE_API_BASE` | ja | Basis-URL des Core (`/auth/module-token`) |
|
||||
| `NEXT_PUBLIC_LOGIN_URL` | nein | Ziel der Weiterleitung, wenn die Sitzung fehlt |
|
||||
|
||||
Server-seitig (nur Route `/api/visible-modules`, nie im Browser sichtbar):
|
||||
|
||||
| Variable | Pflicht | Bedeutung |
|
||||
|---|---|---|
|
||||
| `PORTAL_POLICY_API_URL` | ja | Basis-URL der policy-api (`POST /authorize`) |
|
||||
| `PORTAL_POLICY_SERVICE_TOKEN` | ja | Dienst-Token für die policy-api (Secret) |
|
||||
| `PORTAL_CORE_API_URL` | nein | Basis-URL für `/auth/module-jwks`; Standard: `NEXT_PUBLIC_CORE_API_BASE` |
|
||||
| `PORTAL_TOKEN_ISSUER` | nein | Wenn gesetzt, muss der Token-Aussteller (`iss`) übereinstimmen |
|
||||
| `PORTAL_TOKEN_AUDIENCE` | nein | Wenn gesetzt, muss das Token dieses Zielmodul (`aud`) tragen |
|
||||
| `PORTAL_VISIBLE_MODULES_CACHE_SECONDS` | nein | Cache je Mandant und Rolle, Standard `30`, `0` schaltet ihn ab |
|
||||
| `PORTAL_WORKFLOW_API_URL` | nein | Basis-URL der Workflow-API für den Arbeitsvorrat; ohne sie antwortet `/api/worklist` mit 503 und die Kacheln bleiben ohne Zähler |
|
||||
| `PORTAL_WORKLIST_CACHE_SECONDS` | nein | Cache des Arbeitsvorrats je Mandant und Benutzer, Standard `15` |
|
||||
| `PORTAL_STATUS_API_URL` | nein | Basis-URL der Statusseite (`/status/overview`) für den Betriebsblock (nur Admins) |
|
||||
| `PORTAL_KENNZAHLEN_API_URL` | nein | Basis-URL der Kennzahlen-API (`/kennzahlen/totals`) für die Summenzeile (Recht `kennzahlen.read`) |
|
||||
| `NEXARCH_MODULE_URL_<KEY>` | nein | Ersetzt den festen Port-Href eines Moduls (z. B. `NEXARCH_MODULE_URL_DMS=https://dms.example`), ohne Rebuild |
|
||||
|
||||
Fehlt `PORTAL_POLICY_API_URL`, `PORTAL_POLICY_SERVICE_TOKEN` oder die Core-URL, antwortet die Route mit 503.
|
||||
|
||||
## Verhalten der Route
|
||||
|
||||
- `GET /api/visible-modules` verlangt `Authorization: Bearer <Modul-Token>`; Signatur (Ed25519, JWKS vom Core) und Ablaufzeit werden geprüft, sonst 401.
|
||||
- Mandant und Rolle stammen ausschließlich aus dem Token, nie aus der URL.
|
||||
- Eine Rechteänderung wirkt wegen des Caches bis zu `PORTAL_VISIBLE_MODULES_CACHE_SECONDS` verzögert.
|
||||
- Fällt die policy-api aus, bleibt das jeweilige Modul unsichtbar (fail-closed).
|
||||
|
||||
## Arbeitsvorrat
|
||||
|
||||
`GET /api/worklist` liefert nur Zähler (`approvals`, `tasks`, `remindersDue`), keine Inhalte. Benutzer und Mandant kommen aus dem Token; die Workflow-API kennt bisher nur den Header `X-User-Id` (Übergangsmodus), den das Portal serverseitig setzt. Gezählt werden nur eigene Einträge: Freigaben des Benutzers, offene Aufgaben mit ihm als Person, von ihm angelegte Wiedervorlagen (überfällig oder diese Woche). Fällt eine Quelle aus, ist ihr Wert `null` und die übrigen bleiben erhalten. Die Antwort trägt `Cache-Control: no-store`.
|
||||
|
||||
## Betriebsblock
|
||||
|
||||
`GET /api/system-status` (Recht `core.tenant.admin`) und `GET /api/kennzahlen-summary` (Recht `kennzahlen.read`) prüfen das Recht über die policy-api, bevor sie die Quelle abfragen, und geben nur Modulname, Status, Zeitpunkt bzw. drei Summenwerte zurück (Objekte, Löschvorgänge, Speicher). Die Kennzahlen-Quelle erhält Mandant und Rolle aus dem Token. Die Statusseite ist selbst nicht authentifiziert und darf nur vom Portal-Server aufgerufen werden. Ohne Recht antworten die Routen mit 403, der Block bleibt ausgeblendet.
|
||||
|
||||
## Oberfläche
|
||||
|
||||
Kacheln sind nach Arbeiten, Verwalten und Prüfen gruppiert (`group` in `MODULE_REGISTRY`). Das Mandanten-Label in der Navigation stammt aus der Sitzung. Die Suchzeile leitet an `<DMS-Adresse>/search?q=` weiter und erscheint nur, wenn das DMS für den Benutzer sichtbar ist.
|
||||
|
||||
## Tests
|
||||
|
||||
Auf dem Test-Host (nicht lokal): `npm install && npx vitest run && npx tsc --noEmit`.
|
||||
Bekannt: `tsc` meldet Fehler zu `process` in `web/shl/components/ModuleNavShell.tsx` (`@types/node` fehlt).
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { AccountBar as SharedAccountBar } from "@nexarch/shl";
|
||||
import { getSession } from "../lib/http";
|
||||
|
||||
async function loadIdentity() {
|
||||
const s = getSession();
|
||||
await s.token();
|
||||
return s.identity();
|
||||
}
|
||||
|
||||
export function AccountBar() {
|
||||
return <SharedAccountBar visible onLogout={() => getSession().logout()} loadIdentity={loadIdentity} />;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useI18n } from "@nexarch/shl";
|
||||
import { getSession } from "../lib/http";
|
||||
import type { KennzahlenSummary, ModuleStatusRow } from "../lib/adminData";
|
||||
|
||||
async function getJson<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const res = await getSession().fetch(path);
|
||||
return res.ok ? ((await res.json()) as T) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function statusLabel(status: string): string {
|
||||
return status === "up" ? "in Betrieb" : status === "down" ? "gestört" : "unbekannt";
|
||||
}
|
||||
|
||||
export function AdminPanel() {
|
||||
const { t } = useI18n();
|
||||
const [status, setStatus] = useState<ModuleStatusRow[] | null>(null);
|
||||
const [kpi, setKpi] = useState<KennzahlenSummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const [s, k] = await Promise.all([
|
||||
getJson<{ modules: ModuleStatusRow[] }>("/api/system-status"),
|
||||
getJson<KennzahlenSummary>("/api/kennzahlen-summary"),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setStatus(s?.modules ?? null);
|
||||
setKpi(k);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!status && !kpi) return null;
|
||||
return (
|
||||
<section aria-labelledby="portal-admin" style={{ marginTop: 24 }}>
|
||||
<h2 id="portal-admin" style={{ fontSize: 18 }}>{t("portal.admin", "Betrieb")}</h2>
|
||||
{status && (
|
||||
<ul style={{ listStyle: "none", margin: "0 0 16px", padding: 0 }}>
|
||||
{status.map((m) => (
|
||||
<li key={m.name}>
|
||||
<span aria-hidden="true">{m.status === "up" ? "●" : "○"}</span> {m.name}: {statusLabel(m.status)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{kpi && (
|
||||
<p>
|
||||
{t("portal.kpi.objects", "Objekte")}: {kpi.objektBestand} · {t("portal.kpi.deletions", "Löschvorgänge")}: {kpi.loeschvorgaenge} ·{" "}
|
||||
{t("portal.kpi.storage", "Speicher")}: {(kpi.speichervolumenBytes / 1024 ** 3).toFixed(2)} GB
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,24 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveModuleHref } from "@nexarch/shl";
|
||||
import { fetchVisibleModules } from "./ModuleGrid";
|
||||
import { badgeFor, fetchWorklist, groupModules, fetchVisibleModules } from "./ModuleGrid";
|
||||
|
||||
describe("fetchVisibleModules", () => {
|
||||
it("ruft die Portal-eigene Route mit tenant/role auf und gibt die Modulliste zurueck", async () => {
|
||||
it("ruft die Portal-eigene Route ohne tenant/role in der URL auf und gibt die Modulliste zurueck", async () => {
|
||||
const fetchImpl = vi.fn(
|
||||
async () => new Response(JSON.stringify({ modules: [{ key: "dms", label: "Dokumente", description: "x", href: "http://a", permission: "dms.document.read" }] }), { status: 200 })
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const modules = await fetchVisibleModules("acme", "user", fetchImpl);
|
||||
const modules = await fetchVisibleModules(fetchImpl);
|
||||
|
||||
expect(modules).toHaveLength(1);
|
||||
expect(modules[0].key).toBe("dms");
|
||||
const calledUrl = (fetchImpl as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("tenant=acme");
|
||||
expect(calledUrl).toContain("role=user");
|
||||
expect(calledUrl).toBe("/api/visible-modules");
|
||||
});
|
||||
|
||||
it("gibt eine leere Liste zurueck wenn die Route fehlschlaegt", async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response("", { status: 500 })) as unknown as typeof fetch;
|
||||
const modules = await fetchVisibleModules("acme", "user", fetchImpl);
|
||||
const modules = await fetchVisibleModules(fetchImpl);
|
||||
expect(modules).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -37,3 +36,27 @@ describe("resolveModuleHref", () => {
|
||||
expect(resolveModuleHref("http://localhost:3107/", "localhost")).toBe("http://localhost:3107/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupModules / badgeFor / fetchWorklist", () => {
|
||||
const m = (key: string, group?: "work" | "admin" | "audit") => ({ key, group, label: key, description: "", href: "http://a", permission: "p" });
|
||||
|
||||
it("gruppiert in Reihenfolge Arbeiten, Verwalten, Pruefen und sortiert Unbekanntes unter Arbeiten ein", () => {
|
||||
const g = groupModules([m("a", "audit"), m("b", "admin"), m("c", "work"), m("d")]);
|
||||
expect(g.map((x) => x.key)).toEqual(["work", "admin", "audit"]);
|
||||
expect(g[0].modules.map((x) => x.key)).toEqual(["c", "d"]);
|
||||
});
|
||||
|
||||
it("zeigt Badges nur fuer Workflow-Kacheln und summiert Freigaben und Aufgaben", () => {
|
||||
const w = { approvals: 2, tasks: 3, remindersDue: 4 };
|
||||
expect(badgeFor("workflow", w)).toBe(5);
|
||||
expect(badgeFor("workflow_cases", w)).toBe(4);
|
||||
expect(badgeFor("dms", w)).toBeNull();
|
||||
expect(badgeFor("workflow", null)).toBeNull();
|
||||
expect(badgeFor("workflow", { approvals: null, tasks: null, remindersDue: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("fetchWorklist liefert null bei Fehlern", async () => {
|
||||
expect(await fetchWorklist((async () => new Response("", { status: 500 })) as unknown as typeof fetch)).toBeNull();
|
||||
expect(await fetchWorklist((async () => { throw new Error("x"); }) as unknown as typeof fetch)).toBeNull();
|
||||
});
|
||||
});
|
||||
+115
-41
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useI18n, resolveModuleHref } from "@nexarch/shl";
|
||||
import type { ModuleEntry } from "../lib/modules";
|
||||
import type { Worklist } from "../lib/worklist";
|
||||
import { getSession } from "../lib/http";
|
||||
|
||||
interface VisibleModulesResponse {
|
||||
@@ -10,23 +11,51 @@ interface VisibleModulesResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function fetchVisibleModules(
|
||||
tenant: string,
|
||||
role: string,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<ModuleEntry[]> {
|
||||
const res = await fetchImpl(
|
||||
`/api/visible-modules?tenant=${encodeURIComponent(tenant)}&role=${encodeURIComponent(role)}`
|
||||
);
|
||||
export async function fetchVisibleModules(fetchImpl: typeof fetch): Promise<ModuleEntry[]> {
|
||||
const res = await fetchImpl("/api/visible-modules");
|
||||
if (!res.ok) return [];
|
||||
const body = (await res.json()) as VisibleModulesResponse;
|
||||
return body.modules ?? [];
|
||||
}
|
||||
|
||||
export async function fetchWorklist(fetchImpl: typeof fetch): Promise<Worklist | null> {
|
||||
try {
|
||||
const res = await fetchImpl("/api/worklist");
|
||||
return res.ok ? ((await res.json()) as Worklist) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const GROUPS = [
|
||||
{ key: "work", title: "Arbeiten" },
|
||||
{ key: "admin", title: "Verwalten" },
|
||||
{ key: "audit", title: "Prüfen" },
|
||||
] as const;
|
||||
|
||||
export function groupModules(modules: ModuleEntry[]): { key: string; title: string; modules: ModuleEntry[] }[] {
|
||||
const known = new Set<string>(GROUPS.map((g) => g.key));
|
||||
const out = GROUPS.map((g) => ({ ...g, modules: modules.filter((m) => m.group === g.key) }));
|
||||
const rest = modules.filter((m) => !m.group || !known.has(m.group));
|
||||
if (rest.length > 0) out[0].modules.push(...rest);
|
||||
return out.filter((g) => g.modules.length > 0);
|
||||
}
|
||||
|
||||
export function badgeFor(key: string, w: Worklist | null): number | null {
|
||||
if (!w) return null;
|
||||
if (key === "workflow") {
|
||||
const parts = [w.approvals, w.tasks].filter((n): n is number => n !== null);
|
||||
return parts.length ? parts.reduce((a, b) => a + b, 0) : null;
|
||||
}
|
||||
if (key === "workflow_cases") return w.remindersDue;
|
||||
return null;
|
||||
}
|
||||
|
||||
type State =
|
||||
| { status: "loading" }
|
||||
| { status: "noIdentity" }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "ready"; modules: ModuleEntry[] };
|
||||
| { status: "ready"; modules: ModuleEntry[]; worklist: Worklist | null };
|
||||
|
||||
export function ModuleGrid() {
|
||||
const { t } = useI18n();
|
||||
@@ -38,13 +67,13 @@ export function ModuleGrid() {
|
||||
try {
|
||||
const session = getSession();
|
||||
await session.token();
|
||||
const identity = session.identity();
|
||||
if (!identity) {
|
||||
if (!cancelled) setState({ status: "error", message: t("portal.noIdentity", "Anmeldung erforderlich.") });
|
||||
if (!session.identity()) {
|
||||
if (!cancelled) setState({ status: "noIdentity" });
|
||||
return;
|
||||
}
|
||||
const modules = await fetchVisibleModules(identity.tenant, identity.role);
|
||||
if (!cancelled) setState({ status: "ready", modules });
|
||||
const call = (input: RequestInfo | URL, init?: RequestInit) => session.fetch(input, init);
|
||||
const [modules, worklist] = await Promise.all([fetchVisibleModules(call), fetchWorklist(call)]);
|
||||
if (!cancelled) setState({ status: "ready", modules, worklist });
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setState({ status: "error", message: (err as Error).message });
|
||||
@@ -54,44 +83,89 @@ export function ModuleGrid() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
if (state.status === "loading") return <p>{t("portal.loading", "Lade Übersicht …")}</p>;
|
||||
if (state.status === "noIdentity") return <p role="alert">{t("portal.noIdentity", "Anmeldung erforderlich.")}</p>;
|
||||
if (state.status === "error") return <p role="alert">{state.message}</p>;
|
||||
if (state.modules.length === 0) {
|
||||
return <p>{t("portal.noModules", "Für Ihr Konto sind derzeit keine Module freigeschaltet.")}</p>;
|
||||
}
|
||||
|
||||
const { modules, worklist } = state;
|
||||
const dms = modules.find((m) => m.key === "dms");
|
||||
const today = [
|
||||
{ label: t("portal.approvals", "Offene Freigaben"), value: worklist?.approvals ?? null },
|
||||
{ label: t("portal.tasks", "Meine Aufgaben"), value: worklist?.tasks ?? null },
|
||||
{ label: t("portal.reminders", "Fällige Wiedervorlagen"), value: worklist?.remindersDue ?? null },
|
||||
].filter((x) => x.value !== null);
|
||||
|
||||
return (
|
||||
<ul
|
||||
style={{
|
||||
listStyle: "none",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{state.modules.map((mod) => (
|
||||
<li key={mod.key}>
|
||||
<a
|
||||
href={resolveModuleHref(mod.href)}
|
||||
<div>
|
||||
{dms && (
|
||||
<form action={`${resolveModuleHref(dms.href).replace(/\/$/, "")}/search`} method="get" role="search" style={{ marginBottom: 24 }}>
|
||||
<input
|
||||
name="q"
|
||||
type="search"
|
||||
aria-label={t("portal.search", "Dokumente durchsuchen")}
|
||||
placeholder={t("portal.search", "Dokumente durchsuchen")}
|
||||
style={{ width: "100%", boxSizing: "border-box", minHeight: 44, padding: "0 12px" }}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
{today.length > 0 && (
|
||||
<section aria-label={t("portal.today", "Heute")} style={{ display: "flex", gap: 16, flexWrap: "wrap", marginBottom: 24 }}>
|
||||
{today.map((x) => (
|
||||
<div key={x.label} style={{ padding: 12, border: "1px solid var(--shl-color-border, #d7dbe0)", borderRadius: 8, minWidth: 160 }}>
|
||||
<strong style={{ fontSize: 24 }}>{x.value}</strong>
|
||||
<div>{x.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{groupModules(modules).map((g) => (
|
||||
<section key={g.key} aria-labelledby={`grp-${g.key}`} style={{ marginBottom: 24 }}>
|
||||
<h2 id={`grp-${g.key}`} style={{ fontSize: 18 }}>{t(`portal.group.${g.key}`, g.title)}</h2>
|
||||
<ul
|
||||
style={{
|
||||
display: "block",
|
||||
minHeight: 44,
|
||||
padding: 16,
|
||||
border: "1px solid var(--shl-color-border, #d7dbe0)",
|
||||
borderRadius: 8,
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
listStyle: "none",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<strong>{mod.label}</strong>
|
||||
<p style={{ margin: "4px 0 0" }}>{mod.description}</p>
|
||||
</a>
|
||||
</li>
|
||||
{g.modules.map((mod) => {
|
||||
const badge = badgeFor(mod.key, worklist);
|
||||
return (
|
||||
<li key={mod.key}>
|
||||
<a
|
||||
href={resolveModuleHref(mod.href)}
|
||||
style={{
|
||||
display: "block",
|
||||
minHeight: 44,
|
||||
padding: 16,
|
||||
border: "1px solid var(--shl-color-border, #d7dbe0)",
|
||||
borderRadius: 8,
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
}}
|
||||
>
|
||||
<strong>{mod.label}</strong>
|
||||
{badge !== null && badge > 0 && (
|
||||
<span aria-label={`${badge}`} style={{ marginLeft: 8, padding: "0 8px", borderRadius: 12, border: "1px solid currentColor" }}>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
<p style={{ margin: "4px 0 0" }}>{mod.description}</p>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { ModuleNavShell } from "@nexarch/shl";
|
||||
import { getSession } from "../lib/http";
|
||||
|
||||
export function PortalShell({ children }: { children: ReactNode }) {
|
||||
const [tenant, setTenant] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const s = getSession();
|
||||
s.token().then(
|
||||
() => {
|
||||
if (!cancelled) setTenant(s.identity()?.tenant);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ModuleNavShell activeKey="portal" tenantLabel={tenant}>
|
||||
{children}
|
||||
</ModuleNavShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authenticate, coreConfigured } from "../../../lib/auth";
|
||||
import { kennzahlenCache } from "../../../lib/caches";
|
||||
import { loadKennzahlenSummary } from "../../../lib/adminData";
|
||||
import { policyAuthorize } from "../../../lib/policy";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CACHE_TTL_MS = 15_000;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const policyBase = process.env.PORTAL_POLICY_API_URL;
|
||||
const policyToken = process.env.PORTAL_POLICY_SERVICE_TOKEN;
|
||||
const base = process.env.PORTAL_KENNZAHLEN_API_URL;
|
||||
if (!policyBase || !policyToken || !base || !coreConfigured()) {
|
||||
return NextResponse.json({ error: "Portal nicht konfiguriert" }, { status: 503 });
|
||||
}
|
||||
const claims = await authenticate(request);
|
||||
if (!claims) return NextResponse.json({ error: "Anmeldung erforderlich" }, { status: 401 });
|
||||
|
||||
let allowed = false;
|
||||
try {
|
||||
allowed = await policyAuthorize(policyBase, policyToken)(claims.tenant, claims.role, "kennzahlen.read");
|
||||
} catch {
|
||||
allowed = false;
|
||||
}
|
||||
if (!allowed) return NextResponse.json({ error: "Keine Berechtigung" }, { status: 403 });
|
||||
|
||||
const noStore = { "Cache-Control": "no-store" };
|
||||
const key = `${claims.tenant}\u0000${claims.role}`;
|
||||
const hit = kennzahlenCache.get(key, CACHE_TTL_MS);
|
||||
if (hit) return NextResponse.json(hit, { headers: noStore });
|
||||
|
||||
const data = await loadKennzahlenSummary(base, claims.tenant, claims.role);
|
||||
if (data === null) return NextResponse.json({ error: "Quelle nicht erreichbar" }, { status: 502, headers: noStore });
|
||||
kennzahlenCache.set(key, data);
|
||||
return NextResponse.json(data, { headers: noStore });
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { generateKeyPairSync, sign } from "node:crypto";
|
||||
import { resetKeyCacheForTests } from "../../../lib/verifyToken";
|
||||
import { kennzahlenCache, statusCache } from "../../../lib/caches";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
||||
const rawPub = (publicKey.export({ format: "der", type: "spki" }) as Buffer).subarray(-32);
|
||||
|
||||
function mint(claims: Record<string, unknown>): string {
|
||||
const enc = (o: unknown) => Buffer.from(JSON.stringify(o)).toString("base64url");
|
||||
const head = enc({ alg: "EdDSA", typ: "JWT", kid: "k1" });
|
||||
const body = enc({ exp: Math.floor(Date.now() / 1000) + 300, ...claims });
|
||||
return `${head}.${body}.${sign(null, Buffer.from(`${head}.${body}`), privateKey).toString("base64url")}`;
|
||||
}
|
||||
|
||||
describe("Admin-Routen: system-status und kennzahlen-summary", () => {
|
||||
let allowedPerms: Set<string>;
|
||||
let upstream: string[];
|
||||
|
||||
const req = (path: string, token?: string) =>
|
||||
new NextRequest(`http://portal.example${path}`, { headers: token ? { authorization: `Bearer ${token}` } : {} });
|
||||
|
||||
beforeEach(() => {
|
||||
for (const [k, v] of Object.entries({
|
||||
PORTAL_POLICY_API_URL: "http://policy.example",
|
||||
PORTAL_POLICY_SERVICE_TOKEN: "svc",
|
||||
PORTAL_CORE_API_URL: "http://core.example",
|
||||
PORTAL_STATUS_API_URL: "http://status.example",
|
||||
PORTAL_KENNZAHLEN_API_URL: "http://kz.example",
|
||||
})) process.env[k] = v;
|
||||
resetKeyCacheForTests();
|
||||
statusCache.clear();
|
||||
kennzahlenCache.clear();
|
||||
allowedPerms = new Set();
|
||||
upstream = [];
|
||||
global.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.endsWith("/auth/module-jwks")) return new Response(JSON.stringify({ keys: [{ kid: "k1", public_key: rawPub.toString("base64") }] }), { status: 200 });
|
||||
if (u.endsWith("/authorize")) {
|
||||
const b = JSON.parse(String(init?.body));
|
||||
return new Response(JSON.stringify({ allowed: allowedPerms.has(b.permission) }), { status: 200 });
|
||||
}
|
||||
upstream.push(u);
|
||||
if (u.includes("/status/overview")) return new Response(JSON.stringify([{ name: "dms", status: "up" }]), { status: 200 });
|
||||
return new Response(JSON.stringify({ objekt_bestand: 1, loeschvorgaenge: 0, speichervolumen_bytes: 0 }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
});
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
for (const k of ["PORTAL_POLICY_API_URL", "PORTAL_POLICY_SERVICE_TOKEN", "PORTAL_CORE_API_URL", "PORTAL_STATUS_API_URL", "PORTAL_KENNZAHLEN_API_URL"]) delete process.env[k];
|
||||
});
|
||||
|
||||
it("system-status: 401 ohne Token, 403 ohne Admin-Recht, 200 mit Recht", async () => {
|
||||
const { GET } = await import("./route");
|
||||
const token = mint({ sub: "u", tenant: "acme", role: "user" });
|
||||
expect((await GET(req("/api/system-status"))).status).toBe(401);
|
||||
expect((await GET(req("/api/system-status", token))).status).toBe(403);
|
||||
expect(upstream).toEqual([]);
|
||||
allowedPerms.add("core.tenant.admin");
|
||||
const res = await GET(req("/api/system-status", token));
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).modules[0].name).toBe("dms");
|
||||
});
|
||||
|
||||
it("kennzahlen-summary: Mandant aus dem Token, 403 ohne kennzahlen.read", async () => {
|
||||
const { GET } = await import("../kennzahlen-summary/route");
|
||||
const token = mint({ sub: "u", tenant: "acme", role: "auditor" });
|
||||
expect((await GET(req("/api/kennzahlen-summary?tenant=fremd", token))).status).toBe(403);
|
||||
allowedPerms.add("kennzahlen.read");
|
||||
const res = await GET(req("/api/kennzahlen-summary?tenant=fremd", token));
|
||||
expect(res.status).toBe(200);
|
||||
expect(upstream[0]).toContain("tenant=acme");
|
||||
expect(upstream[0]).not.toContain("fremd");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authenticate, coreConfigured } from "../../../lib/auth";
|
||||
import { statusCache } from "../../../lib/caches";
|
||||
import { loadSystemStatus } from "../../../lib/adminData";
|
||||
import { policyAuthorize } from "../../../lib/policy";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CACHE_TTL_MS = 15_000;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const policyBase = process.env.PORTAL_POLICY_API_URL;
|
||||
const policyToken = process.env.PORTAL_POLICY_SERVICE_TOKEN;
|
||||
const base = process.env.PORTAL_STATUS_API_URL;
|
||||
if (!policyBase || !policyToken || !base || !coreConfigured()) {
|
||||
return NextResponse.json({ error: "Portal nicht konfiguriert" }, { status: 503 });
|
||||
}
|
||||
const claims = await authenticate(request);
|
||||
if (!claims) return NextResponse.json({ error: "Anmeldung erforderlich" }, { status: 401 });
|
||||
|
||||
let allowed = false;
|
||||
try {
|
||||
allowed = await policyAuthorize(policyBase, policyToken)(claims.tenant, claims.role, "core.tenant.admin");
|
||||
} catch {
|
||||
allowed = false;
|
||||
}
|
||||
if (!allowed) return NextResponse.json({ error: "Keine Berechtigung" }, { status: 403 });
|
||||
|
||||
const noStore = { "Cache-Control": "no-store" };
|
||||
const key = "all";
|
||||
const hit = statusCache.get(key, CACHE_TTL_MS);
|
||||
if (hit) return NextResponse.json({ modules: hit }, { headers: noStore });
|
||||
|
||||
const data = await loadSystemStatus(base);
|
||||
if (data === null) return NextResponse.json({ error: "Quelle nicht erreichbar" }, { status: 502, headers: noStore });
|
||||
statusCache.set(key, data);
|
||||
return NextResponse.json({ modules: data }, { headers: noStore });
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { filterVisibleModules, type Authorize } from "./route";
|
||||
import { generateKeyPairSync, sign } from "node:crypto";
|
||||
import { filterVisibleModules, type Authorize } from "../../../lib/policy";
|
||||
import type { ModuleEntry } from "../../../lib/modules";
|
||||
import { resetKeyCacheForTests } from "../../../lib/verifyToken";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
@@ -49,40 +51,91 @@ describe("filterVisibleModules (Pruefungen 1 und 4)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
||||
const rawPub = (publicKey.export({ format: "der", type: "spki" }) as Buffer).subarray(-32);
|
||||
|
||||
function mint(claims: Record<string, unknown>, kid = "k1", key = privateKey): string {
|
||||
const enc = (o: unknown) => Buffer.from(JSON.stringify(o)).toString("base64url");
|
||||
const head = enc({ alg: "EdDSA", typ: "JWT", kid });
|
||||
const body = enc({ exp: Math.floor(Date.now() / 1000) + 300, ...claims });
|
||||
return `${head}.${body}.${sign(null, Buffer.from(`${head}.${body}`), key).toString("base64url")}`;
|
||||
}
|
||||
|
||||
describe("GET /api/visible-modules (HTTP-Schicht)", () => {
|
||||
beforeEach(() => {
|
||||
let policyCalls: { tenant: string; role: string }[];
|
||||
|
||||
function req(token?: string, query = ""): NextRequest {
|
||||
return new NextRequest(`http://portal.example/api/visible-modules${query}`, {
|
||||
headers: token ? { authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.PORTAL_POLICY_API_URL = "http://policy.example";
|
||||
process.env.PORTAL_POLICY_SERVICE_TOKEN = "test-token";
|
||||
process.env.PORTAL_CORE_API_URL = "http://core.example";
|
||||
policyCalls = [];
|
||||
resetKeyCacheForTests();
|
||||
(await import("../../../lib/caches")).visibleModulesCache.clear();
|
||||
global.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.endsWith("/auth/module-jwks")) {
|
||||
return new Response(JSON.stringify({ keys: [{ kid: "k1", public_key: rawPub.toString("base64") }] }), { status: 200 });
|
||||
}
|
||||
policyCalls.push(JSON.parse(String(init?.body)));
|
||||
return new Response(JSON.stringify({ allowed: true }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
});
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
delete process.env.PORTAL_POLICY_API_URL;
|
||||
delete process.env.PORTAL_POLICY_SERVICE_TOKEN;
|
||||
for (const k of ["PORTAL_POLICY_API_URL", "PORTAL_POLICY_SERVICE_TOKEN", "PORTAL_CORE_API_URL", "PORTAL_TOKEN_AUDIENCE"]) delete process.env[k];
|
||||
});
|
||||
|
||||
it("gibt 400 ohne tenant/role zurueck", async () => {
|
||||
it("gibt 401 ohne Token zurueck", async () => {
|
||||
const { GET } = await import("./route");
|
||||
const req = new NextRequest("http://portal.example/api/visible-modules");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(400);
|
||||
expect((await GET(req())).status).toBe(401);
|
||||
});
|
||||
|
||||
it("gibt 401 bei falscher Signatur, abgelaufenem Token oder unbekannter kid", async () => {
|
||||
const { GET } = await import("./route");
|
||||
const other = generateKeyPairSync("ed25519").privateKey;
|
||||
const claims = { sub: "u", tenant: "acme", role: "user" };
|
||||
expect((await GET(req(mint(claims, "k1", other)))).status).toBe(401);
|
||||
expect((await GET(req(mint({ ...claims, exp: 1 })))).status).toBe(401);
|
||||
expect((await GET(req(mint(claims, "unbekannt")))).status).toBe(401);
|
||||
});
|
||||
|
||||
it("gibt 503 zurueck wenn Portal nicht konfiguriert ist", async () => {
|
||||
delete process.env.PORTAL_POLICY_API_URL;
|
||||
const { GET } = await import("./route");
|
||||
const req = new NextRequest("http://portal.example/api/visible-modules?tenant=acme&role=user");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(503);
|
||||
expect((await GET(req(mint({ sub: "u", tenant: "acme", role: "user" })))).status).toBe(503);
|
||||
});
|
||||
|
||||
it("ruft policy-api mit Dienst-Token auf, Token nie im Response sichtbar", async () => {
|
||||
global.fetch = vi.fn(async () => new Response(JSON.stringify({ allowed: true }), { status: 200 })) as typeof fetch;
|
||||
it("nimmt Mandant und Rolle aus dem Token, nicht aus der URL", async () => {
|
||||
const { GET } = await import("./route");
|
||||
const req = new NextRequest("http://portal.example/api/visible-modules?tenant=acme&role=user");
|
||||
const res = await GET(req);
|
||||
const body = await res.json();
|
||||
const res = await GET(req(mint({ sub: "u", tenant: "acme", role: "user" }), "?tenant=fremd&role=admin"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(JSON.stringify(body)).not.toContain("test-token");
|
||||
expect((global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].headers["X-Service-Token"]).toBe("test-token");
|
||||
expect(policyCalls.length).toBeGreaterThan(0);
|
||||
expect(policyCalls.every((c) => c.tenant === "acme" && c.role === "user")).toBe(true);
|
||||
expect(JSON.stringify(await res.json())).not.toContain("test-token");
|
||||
});
|
||||
|
||||
it("verlangt das konfigurierte Zielmodul", async () => {
|
||||
process.env.PORTAL_TOKEN_AUDIENCE = "portal";
|
||||
const { GET } = await import("./route");
|
||||
const claims = { sub: "u", tenant: "acme", role: "user" };
|
||||
expect((await GET(req(mint({ ...claims, aud: ["dms"] })))).status).toBe(401);
|
||||
expect((await GET(req(mint({ ...claims, aud: ["portal"] })))).status).toBe(200);
|
||||
});
|
||||
|
||||
it("fragt die policy-api je Mandant/Rolle nur einmal innerhalb der Cache-Zeit", async () => {
|
||||
const { GET } = await import("./route");
|
||||
const token = mint({ sub: "u", tenant: "acme", role: "user" });
|
||||
await GET(req(token));
|
||||
const first = policyCalls.length;
|
||||
await GET(req(token));
|
||||
expect(policyCalls.length).toBe(first);
|
||||
await GET(req(mint({ sub: "u", tenant: "acme", role: "admin" })));
|
||||
expect(policyCalls.length).toBeGreaterThan(first);
|
||||
});
|
||||
});
|
||||
@@ -1,65 +1,41 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { loadModuleRegistry, type ModuleEntry } from "../../../lib/modules";
|
||||
import { applyUrlOverrides, loadModuleRegistry } from "../../../lib/modules";
|
||||
import { authenticate, coreConfigured } from "../../../lib/auth";
|
||||
import { visibleModulesCache } from "../../../lib/caches";
|
||||
import { filterVisibleModules, policyAuthorize } from "../../../lib/policy";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export type Authorize = (tenant: string, role: string, permission: string) => Promise<boolean>;
|
||||
|
||||
// Reine, testbare Kernlogik: iteriert generisch ueber JEDE Registry, egal
|
||||
// wie viele/welche Eintraege config/modules.json enthaelt (Akzeptanz-
|
||||
// kriterium 3 -- ein neues Modul braucht hier keine Codeaenderung).
|
||||
// Ausfall/Verweigerung von 'authorize' lassen ein Modul nie durch
|
||||
// (fail-closed, Pruefung 4: kein Modul ohne Berechtigung sichtbar).
|
||||
export async function filterVisibleModules(
|
||||
registry: ModuleEntry[],
|
||||
tenant: string,
|
||||
role: string,
|
||||
authorize: Authorize
|
||||
): Promise<ModuleEntry[]> {
|
||||
const results = await Promise.all(
|
||||
registry.map(async (mod) => {
|
||||
try {
|
||||
return (await authorize(tenant, role, mod.permission)) ? mod : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
return results.filter((m): m is ModuleEntry => m !== null);
|
||||
}
|
||||
|
||||
function policyAuthorize(base: string, token: string): Authorize {
|
||||
return async (tenant, role, permission) => {
|
||||
const res = await fetch(`${base.replace(/\/$/, "")}/authorize`, {
|
||||
method: "POST",
|
||||
headers: { "X-Service-Token": token, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tenant, role, permission }),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const body = (await res.json()) as { allowed?: boolean };
|
||||
return body.allowed === true;
|
||||
};
|
||||
}
|
||||
const CACHE_TTL_MS = Number(process.env.PORTAL_VISIBLE_MODULES_CACHE_SECONDS ?? "30") * 1000;
|
||||
|
||||
// Mandant und Rolle kommen ausschliesslich aus dem verifizierten Token,
|
||||
// nie aus der URL. Der Cache haelt nur positive Ergebnisse je Mandant/Rolle
|
||||
// kurz vor; Rechteaenderungen wirken bis zu CACHE_TTL_MS verzoegert.
|
||||
// Backend-for-Frontend: haelt das Dienst-Token fuer policy-api server-
|
||||
// seitig (der Browser bekommt es nie zu Gesicht, gleiches Prinzip wie
|
||||
// tenant-admins Proxy-Route). Core entscheidet ueber Rolle UND Feature-
|
||||
// Flag in einem Aufruf (AuthorizeRequest), das Portal trifft keine
|
||||
// eigene zweite Entscheidung (Akzeptanzkriterium 2).
|
||||
export async function GET(request: NextRequest) {
|
||||
const tenant = request.nextUrl.searchParams.get("tenant");
|
||||
const role = request.nextUrl.searchParams.get("role");
|
||||
if (!tenant || !role) {
|
||||
return NextResponse.json({ error: "'tenant' und 'role' sind erforderlich" }, { status: 400 });
|
||||
}
|
||||
|
||||
const base = process.env.PORTAL_POLICY_API_URL;
|
||||
const token = process.env.PORTAL_POLICY_SERVICE_TOKEN;
|
||||
if (!base || !token) {
|
||||
if (!base || !token || !coreConfigured()) {
|
||||
return NextResponse.json({ error: "Portal nicht konfiguriert" }, { status: 503 });
|
||||
}
|
||||
|
||||
const modules = await filterVisibleModules(loadModuleRegistry(), tenant, role, policyAuthorize(base, token));
|
||||
const claims = await authenticate(request);
|
||||
if (!claims) {
|
||||
return NextResponse.json({ error: "Anmeldung erforderlich" }, { status: 401 });
|
||||
}
|
||||
|
||||
const cacheKey = `${claims.tenant}\u0000${claims.role}`;
|
||||
const hit = visibleModulesCache.get(cacheKey, CACHE_TTL_MS);
|
||||
if (hit) return NextResponse.json({ modules: hit });
|
||||
|
||||
const modules = applyUrlOverrides(
|
||||
await filterVisibleModules(loadModuleRegistry(), claims.tenant, claims.role, policyAuthorize(base, token)),
|
||||
process.env
|
||||
);
|
||||
visibleModulesCache.set(cacheKey, modules);
|
||||
return NextResponse.json({ modules });
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { generateKeyPairSync, sign } from "node:crypto";
|
||||
import { resetKeyCacheForTests } from "../../../lib/verifyToken";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
||||
const rawPub = (publicKey.export({ format: "der", type: "spki" }) as Buffer).subarray(-32);
|
||||
|
||||
function mint(claims: Record<string, unknown>): string {
|
||||
const enc = (o: unknown) => Buffer.from(JSON.stringify(o)).toString("base64url");
|
||||
const head = enc({ alg: "EdDSA", typ: "JWT", kid: "k1" });
|
||||
const body = enc({ exp: Math.floor(Date.now() / 1000) + 300, ...claims });
|
||||
return `${head}.${body}.${sign(null, Buffer.from(`${head}.${body}`), privateKey).toString("base64url")}`;
|
||||
}
|
||||
|
||||
describe("GET /api/worklist", () => {
|
||||
let wfCalls: { url: string; user: string }[];
|
||||
|
||||
const req = (token?: string, query = "") =>
|
||||
new NextRequest(`http://portal.example/api/worklist${query}`, { headers: token ? { authorization: `Bearer ${token}` } : {} });
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.PORTAL_WORKFLOW_API_URL = "http://wf.example";
|
||||
process.env.PORTAL_CORE_API_URL = "http://core.example";
|
||||
wfCalls = [];
|
||||
resetKeyCacheForTests();
|
||||
(await import("../../../lib/caches")).worklistCache.clear();
|
||||
global.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.endsWith("/auth/module-jwks")) {
|
||||
return new Response(JSON.stringify({ keys: [{ kid: "k1", public_key: rawPub.toString("base64") }] }), { status: 200 });
|
||||
}
|
||||
wfCalls.push({ url: u, user: (init?.headers as Record<string, string>)["X-User-Id"] });
|
||||
return new Response(JSON.stringify([]), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
});
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
delete process.env.PORTAL_WORKFLOW_API_URL;
|
||||
delete process.env.PORTAL_CORE_API_URL;
|
||||
});
|
||||
|
||||
it("gibt 401 ohne Token und 503 ohne Konfiguration zurueck", async () => {
|
||||
const { GET } = await import("./route");
|
||||
expect((await GET(req())).status).toBe(401);
|
||||
delete process.env.PORTAL_WORKFLOW_API_URL;
|
||||
expect((await GET(req(mint({ sub: "u1", tenant: "acme" })))).status).toBe(503);
|
||||
});
|
||||
|
||||
it("nimmt den Benutzer aus dem Token, nie aus der URL, und sendet no-store", async () => {
|
||||
const { GET } = await import("./route");
|
||||
const res = await GET(req(mint({ sub: "u1", tenant: "acme", role: "user" }), "?userId=fremd"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("cache-control")).toBe("no-store");
|
||||
expect(wfCalls.length).toBe(3);
|
||||
expect(wfCalls.every((c) => c.user === "u1")).toBe(true);
|
||||
});
|
||||
|
||||
it("cached je Mandant und Benutzer", async () => {
|
||||
const { GET } = await import("./route");
|
||||
await GET(req(mint({ sub: "u1", tenant: "acme" })));
|
||||
await GET(req(mint({ sub: "u1", tenant: "acme" })));
|
||||
expect(wfCalls.length).toBe(3);
|
||||
await GET(req(mint({ sub: "u2", tenant: "acme" })));
|
||||
expect(wfCalls.length).toBe(6);
|
||||
expect(wfCalls.slice(3).every((c) => c.user === "u2")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authenticate, coreConfigured } from "../../../lib/auth";
|
||||
import { worklistCache } from "../../../lib/caches";
|
||||
import { loadWorklist } from "../../../lib/worklist";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CACHE_TTL_MS = Number(process.env.PORTAL_WORKLIST_CACHE_SECONDS ?? "15") * 1000;
|
||||
// Nur Zaehler, keine Inhalte. Benutzer und Mandant stammen aus dem verifizierten Token;
|
||||
// die Workflow-API kennt bisher nur X-User-Id (Uebergangsmodus), der Header wird hier serverseitig gesetzt.
|
||||
export async function GET(request: NextRequest) {
|
||||
const base = process.env.PORTAL_WORKFLOW_API_URL;
|
||||
if (!base || !coreConfigured()) {
|
||||
return NextResponse.json({ error: "Portal nicht konfiguriert" }, { status: 503 });
|
||||
}
|
||||
const claims = await authenticate(request);
|
||||
if (!claims) return NextResponse.json({ error: "Anmeldung erforderlich" }, { status: 401 });
|
||||
|
||||
const noStore = { "Cache-Control": "no-store" };
|
||||
const key = `${claims.tenant}\u0000${claims.sub}`;
|
||||
const hit = worklistCache.get(key, CACHE_TTL_MS);
|
||||
if (hit) return NextResponse.json(hit, { headers: noStore });
|
||||
|
||||
const data = await loadWorklist(base, claims.tenant, claims.sub);
|
||||
worklistCache.set(key, data);
|
||||
return NextResponse.json(data, { headers: noStore });
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import Script from "next/script";
|
||||
import { ThemeProvider, I18nProvider, ToastProvider, ModuleNavShell, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
|
||||
import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
|
||||
import type { ReactNode } from "react";
|
||||
import { AccountBar } from "./AccountBar";
|
||||
import { PortalShell } from "./PortalShell";
|
||||
|
||||
export const metadata = {
|
||||
title: "NEXARCH Portal",
|
||||
@@ -25,7 +27,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
>
|
||||
<ThemeProvider>
|
||||
<I18nProvider initialLocale="de">
|
||||
<ToastProvider><ModuleNavShell activeKey="portal">{children}</ModuleNavShell></ToastProvider>
|
||||
<ToastProvider><PortalShell><AccountBar />{children}</PortalShell></ToastProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AdminPanel } from "./AdminPanel";
|
||||
import { ModuleGrid } from "./ModuleGrid";
|
||||
|
||||
export default function Page() {
|
||||
@@ -5,6 +6,7 @@ export default function Page() {
|
||||
<div style={{ maxWidth: 960, margin: "0 auto", padding: 24 }}>
|
||||
<h1>NEXARCH Portal</h1>
|
||||
<ModuleGrid />
|
||||
<AdminPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loadKennzahlenSummary, loadSystemStatus } from "./adminData";
|
||||
|
||||
describe("loadSystemStatus", () => {
|
||||
it("mappt Name, Status und Zeitpunkt", async () => {
|
||||
const f = vi.fn(async () => new Response(JSON.stringify([{ name: "dms", status: "up", last_checked: "2026-10-27T10:00:00Z", host: "geheim" }]), { status: 200 })) as unknown as typeof fetch;
|
||||
const r = await loadSystemStatus("http://s.example/", f);
|
||||
expect(r).toEqual([{ name: "dms", status: "up", lastChecked: "2026-10-27T10:00:00Z" }]);
|
||||
expect(JSON.stringify(r)).not.toContain("geheim");
|
||||
});
|
||||
|
||||
it("liefert null bei Fehler oder falscher Form", async () => {
|
||||
expect(await loadSystemStatus("http://s", (async () => new Response("", { status: 500 })) as unknown as typeof fetch)).toBeNull();
|
||||
expect(await loadSystemStatus("http://s", (async () => new Response("{}", { status: 200 })) as unknown as typeof fetch)).toBeNull();
|
||||
expect(await loadSystemStatus("http://s", (async () => { throw new Error("x"); }) as unknown as typeof fetch)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadKennzahlenSummary", () => {
|
||||
it("sendet Mandant und Rolle aus den Argumenten und gibt nur die drei Summen zurueck", async () => {
|
||||
const f = vi.fn(async () => new Response(JSON.stringify({ objekt_bestand: 5, loeschvorgaenge: 2, speichervolumen_bytes: 1024, aufbewahrungsstatus: { a: 1 } }), { status: 200 }));
|
||||
const r = await loadKennzahlenSummary("http://k.example", "acme", "auditor", f as unknown as typeof fetch);
|
||||
expect(r).toEqual({ objektBestand: 5, loeschvorgaenge: 2, speichervolumenBytes: 1024 });
|
||||
const [url, init] = f.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe("http://k.example/kennzahlen/totals?tenant=acme");
|
||||
expect((init.headers as Record<string, string>)["X-User-Role"]).toBe("auditor");
|
||||
});
|
||||
|
||||
it("liefert null, wenn die Quelle ablehnt", async () => {
|
||||
expect(await loadKennzahlenSummary("http://k", "t", "r", (async () => new Response("", { status: 403 })) as unknown as typeof fetch)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface ModuleStatusRow {
|
||||
name: string;
|
||||
status: string;
|
||||
lastChecked: string | null;
|
||||
}
|
||||
|
||||
export interface KennzahlenSummary {
|
||||
objektBestand: number;
|
||||
loeschvorgaenge: number;
|
||||
speichervolumenBytes: number;
|
||||
}
|
||||
|
||||
const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
||||
|
||||
export async function loadSystemStatus(base: string, fetchImpl: typeof fetch = fetch): Promise<ModuleStatusRow[] | null> {
|
||||
try {
|
||||
const res = await fetchImpl(`${base.replace(/\/$/, "")}/status/overview`, { cache: "no-store" });
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as unknown;
|
||||
if (!Array.isArray(body)) return null;
|
||||
return body.map((r: { name?: unknown; status?: unknown; last_checked?: unknown }) => ({
|
||||
name: String(r.name ?? ""),
|
||||
status: String(r.status ?? "unknown"),
|
||||
lastChecked: typeof r.last_checked === "string" ? r.last_checked : null,
|
||||
}));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Nur die drei Summenwerte verlassen das Portal; Mandant und Rolle kommen aus dem Token.
|
||||
export async function loadKennzahlenSummary(
|
||||
base: string,
|
||||
tenant: string,
|
||||
role: string,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<KennzahlenSummary | null> {
|
||||
try {
|
||||
const res = await fetchImpl(`${base.replace(/\/$/, "")}/kennzahlen/totals?tenant=${encodeURIComponent(tenant)}`, {
|
||||
headers: { "X-User-Role": role, Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const b = (await res.json()) as Record<string, unknown>;
|
||||
return {
|
||||
objektBestand: num(b.objekt_bestand),
|
||||
loeschvorgaenge: num(b.loeschvorgaenge),
|
||||
speichervolumenBytes: num(b.speichervolumen_bytes),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { verifyModuleToken, type VerifiedClaims } from "./verifyToken";
|
||||
|
||||
export async function authenticate(request: NextRequest): Promise<VerifiedClaims | null> {
|
||||
const coreBase = process.env.PORTAL_CORE_API_URL ?? process.env.NEXT_PUBLIC_CORE_API_BASE;
|
||||
if (!coreBase) return null;
|
||||
const auth = request.headers.get("authorization") ?? "";
|
||||
const bearer = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
|
||||
if (!bearer) return null;
|
||||
return verifyModuleToken(bearer, {
|
||||
jwksUrl: `${coreBase.replace(/\/$/, "")}/auth/module-jwks`,
|
||||
issuer: process.env.PORTAL_TOKEN_ISSUER,
|
||||
audience: process.env.PORTAL_TOKEN_AUDIENCE,
|
||||
});
|
||||
}
|
||||
|
||||
export function coreConfigured(): boolean {
|
||||
return Boolean(process.env.PORTAL_CORE_API_URL ?? process.env.NEXT_PUBLIC_CORE_API_BASE);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ModuleEntry } from "./modules";
|
||||
import type { Worklist } from "./worklist";
|
||||
|
||||
export class TtlCache<T> {
|
||||
private readonly entries = new Map<string, { at: number; value: T }>();
|
||||
|
||||
get(key: string, ttlMs: number): T | undefined {
|
||||
const hit = this.entries.get(key);
|
||||
return hit && Date.now() - hit.at < ttlMs ? hit.value : undefined;
|
||||
}
|
||||
|
||||
set(key: string, value: T): void {
|
||||
this.entries.set(key, { at: Date.now(), value });
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const visibleModulesCache = new TtlCache<ModuleEntry[]>();
|
||||
export const worklistCache = new TtlCache<Worklist>();
|
||||
export const statusCache = new TtlCache<unknown>();
|
||||
export const kennzahlenCache = new TtlCache<unknown>();
|
||||
@@ -83,3 +83,17 @@ describe("loadModuleRegistry", () => {
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyUrlOverrides", () => {
|
||||
const mods = [{ key: "dms", label: "D", description: "x", href: "http://localhost:3101", permission: "p" }];
|
||||
|
||||
it("ersetzt den Href per NEXARCH_MODULE_URL_<KEY>", async () => {
|
||||
const { applyUrlOverrides } = await import("./modules");
|
||||
expect(applyUrlOverrides(mods, { NEXARCH_MODULE_URL_DMS: "https://dms.example" })[0].href).toBe("https://dms.example");
|
||||
});
|
||||
|
||||
it("laesst den Href ohne Override unveraendert", async () => {
|
||||
const { applyUrlOverrides } = await import("./modules");
|
||||
expect(applyUrlOverrides(mods, {})[0].href).toBe("http://localhost:3101");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { MODULE_REGISTRY } from "@nexarch/shl";
|
||||
import { MODULE_REGISTRY, type ModuleGroup } from "@nexarch/shl";
|
||||
|
||||
export interface ModuleEntry {
|
||||
key: string;
|
||||
group?: ModuleGroup;
|
||||
label: string;
|
||||
description: string;
|
||||
href: string;
|
||||
@@ -18,3 +19,11 @@ export function loadModuleRegistry(): ModuleEntry[] {
|
||||
(m): m is ModuleEntry => m.key !== "portal" && m.description !== undefined && m.permission !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
// NEXARCH_MODULE_URL_<KEY> ersetzt den festen Port-Href eines Moduls, ohne Rebuild.
|
||||
export function applyUrlOverrides(modules: ModuleEntry[], env: Record<string, string | undefined>): ModuleEntry[] {
|
||||
return modules.map((m) => {
|
||||
const override = env[`NEXARCH_MODULE_URL_${m.key.toUpperCase()}`];
|
||||
return override ? { ...m, href: override } : m;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ModuleEntry } from "./modules";
|
||||
|
||||
export type Authorize = (tenant: string, role: string, permission: string) => Promise<boolean>;
|
||||
|
||||
// Reine, testbare Kernlogik: iteriert generisch ueber JEDE Registry, egal
|
||||
// wie viele/welche Eintraege config/modules.json enthaelt (Akzeptanz-
|
||||
// kriterium 3 -- ein neues Modul braucht hier keine Codeaenderung).
|
||||
// Ausfall/Verweigerung von 'authorize' lassen ein Modul nie durch
|
||||
// (fail-closed, Pruefung 4: kein Modul ohne Berechtigung sichtbar).
|
||||
export async function filterVisibleModules(
|
||||
registry: ModuleEntry[],
|
||||
tenant: string,
|
||||
role: string,
|
||||
authorize: Authorize
|
||||
): Promise<ModuleEntry[]> {
|
||||
const results = await Promise.all(
|
||||
registry.map(async (mod) => {
|
||||
try {
|
||||
return (await authorize(tenant, role, mod.permission)) ? mod : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
return results.filter((m): m is ModuleEntry => m !== null);
|
||||
}
|
||||
|
||||
export function policyAuthorize(base: string, token: string): Authorize {
|
||||
return async (tenant, role, permission) => {
|
||||
const res = await fetch(`${base.replace(/\/$/, "")}/authorize`, {
|
||||
method: "POST",
|
||||
headers: { "X-Service-Token": token, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tenant, role, permission }),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const body = (await res.json()) as { allowed?: boolean };
|
||||
return body.allowed === true;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createPublicKey, verify, type KeyObject } from "node:crypto";
|
||||
|
||||
export interface VerifiedClaims {
|
||||
sub: string;
|
||||
tenant: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface VerifyOptions {
|
||||
jwksUrl: string;
|
||||
issuer?: string;
|
||||
audience?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
||||
const JWKS_TTL_MS = 60_000;
|
||||
|
||||
let keyCache: { url: string; at: number; keys: Map<string, KeyObject> } | null = null;
|
||||
|
||||
export function resetKeyCacheForTests(): void {
|
||||
keyCache = null;
|
||||
}
|
||||
|
||||
async function loadKeys(opts: VerifyOptions, force: boolean): Promise<Map<string, KeyObject>> {
|
||||
const now = (opts.now ?? Date.now)();
|
||||
if (!force && keyCache && keyCache.url === opts.jwksUrl && now - keyCache.at < JWKS_TTL_MS) return keyCache.keys;
|
||||
const res = await (opts.fetchImpl ?? fetch)(opts.jwksUrl, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`jwks ${res.status}`);
|
||||
const body = (await res.json()) as { keys?: { kid?: string; public_key?: string }[] };
|
||||
const keys = new Map<string, KeyObject>();
|
||||
for (const k of body.keys ?? []) {
|
||||
if (!k.kid || !k.public_key) continue;
|
||||
const raw = Buffer.from(k.public_key, "base64");
|
||||
if (raw.length !== 32) continue;
|
||||
keys.set(k.kid, createPublicKey({ key: Buffer.concat([SPKI_ED25519_PREFIX, raw]), format: "der", type: "spki" }));
|
||||
}
|
||||
keyCache = { url: opts.jwksUrl, at: now, keys };
|
||||
return keys;
|
||||
}
|
||||
|
||||
function parseJson(part: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const v = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
|
||||
return typeof v === "object" && v !== null ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Gibt nur bei gueltiger Signatur, Ablaufzeit und (falls gesetzt) Aussteller/Zielmodul Claims zurueck.
|
||||
export async function verifyModuleToken(token: string, opts: VerifyOptions): Promise<VerifiedClaims | null> {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const header = parseJson(parts[0]);
|
||||
const claims = parseJson(parts[1]);
|
||||
if (!header || !claims || header.alg !== "EdDSA" || typeof header.kid !== "string") return null;
|
||||
|
||||
let keys: Map<string, KeyObject>;
|
||||
try {
|
||||
keys = await loadKeys(opts, false);
|
||||
if (!keys.has(header.kid)) keys = await loadKeys(opts, true);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const key = keys.get(header.kid);
|
||||
if (!key) return null;
|
||||
|
||||
const signed = Buffer.from(`${parts[0]}.${parts[1]}`);
|
||||
const sig = Buffer.from(parts[2], "base64url");
|
||||
if (!verify(null, signed, key, sig)) return null;
|
||||
|
||||
const nowSec = Math.floor((opts.now ?? Date.now)() / 1000);
|
||||
if (typeof claims.exp !== "number" || claims.exp <= nowSec) return null;
|
||||
if (typeof claims.nbf === "number" && claims.nbf > nowSec) return null;
|
||||
if (opts.issuer && claims.iss !== opts.issuer) return null;
|
||||
if (opts.audience) {
|
||||
const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
||||
if (!aud.includes(opts.audience)) return null;
|
||||
}
|
||||
if (typeof claims.sub !== "string" || typeof claims.tenant !== "string" || !claims.sub || !claims.tenant) return null;
|
||||
return { sub: claims.sub, tenant: claims.tenant, role: typeof claims.role === "string" ? claims.role : "" };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { countApprovals, countOwnReminders, countOwnTasks, loadWorklist } from "./worklist";
|
||||
|
||||
describe("Zaehler", () => {
|
||||
it("zaehlt nur eigene, nicht erledigte Personen-Aufgaben", () => {
|
||||
const rows = [
|
||||
{ AssigneeType: "person", AssigneeID: "u1", Status: "open" },
|
||||
{ AssigneeType: "person", AssigneeID: "u1", Status: "done" },
|
||||
{ AssigneeType: "person", AssigneeID: "u2", Status: "open" },
|
||||
{ AssigneeType: "group", AssigneeID: "u1", Status: "open" },
|
||||
];
|
||||
expect(countOwnTasks(rows, "u1")).toBe(1);
|
||||
});
|
||||
|
||||
it("zaehlt nur eigene ueberfaellige oder diese Woche faellige Wiedervorlagen", () => {
|
||||
const rows = [
|
||||
{ Column: "overdue", Reminder: { CreatedBy: "u1" } },
|
||||
{ Column: "this_week", Reminder: { CreatedBy: "u1" } },
|
||||
{ Column: "open", Reminder: { CreatedBy: "u1" } },
|
||||
{ Column: "overdue", Reminder: { CreatedBy: "u2" } },
|
||||
];
|
||||
expect(countOwnReminders(rows, "u1")).toBe(2);
|
||||
});
|
||||
|
||||
it("liefert 0 bei unerwarteter Antwortform", () => {
|
||||
expect(countApprovals(null)).toBe(0);
|
||||
expect(countOwnTasks({}, "u1")).toBe(0);
|
||||
expect(countOwnReminders("x", "u1")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadWorklist", () => {
|
||||
it("setzt X-User-Id und X-Tenant-Slug und gibt nur Zaehler zurueck", async () => {
|
||||
const calls: { url: string; headers: Record<string, string> }[] = [];
|
||||
const fetchImpl = vi.fn(async (url: string, init: RequestInit) => {
|
||||
calls.push({ url, headers: init.headers as Record<string, string> });
|
||||
if (url.endsWith("/approvals")) return new Response(JSON.stringify([{ ID: "a" }, { ID: "b" }]), { status: 200 });
|
||||
if (url.endsWith("/tasks")) return new Response(JSON.stringify([{ AssigneeType: "person", AssigneeID: "u1", Status: "open", Title: "geheim" }]), { status: 200 });
|
||||
return new Response(JSON.stringify([]), { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const w = await loadWorklist("http://wf.example/", "acme", "u1", fetchImpl);
|
||||
|
||||
expect(w).toEqual({ approvals: 2, tasks: 1, remindersDue: 0 });
|
||||
expect(JSON.stringify(w)).not.toContain("geheim");
|
||||
expect(calls.every((c) => c.headers["X-User-Id"] === "u1" && c.headers["X-Tenant-Slug"] === "acme")).toBe(true);
|
||||
});
|
||||
|
||||
it("markiert ausgefallene Quellen mit null, ohne die anderen zu verlieren", async () => {
|
||||
const fetchImpl = vi.fn(async (url: string) => {
|
||||
if (url.endsWith("/tasks")) throw new Error("down");
|
||||
if (url.endsWith("/reminders/kanban")) return new Response("", { status: 500 });
|
||||
return new Response(JSON.stringify([{ ID: "a" }]), { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
expect(await loadWorklist("http://wf.example", "acme", "u1", fetchImpl)).toEqual({ approvals: 1, tasks: null, remindersDue: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
export interface Worklist {
|
||||
approvals: number | null;
|
||||
tasks: number | null;
|
||||
remindersDue: number | null;
|
||||
}
|
||||
|
||||
interface TaskRow {
|
||||
AssigneeType?: string;
|
||||
AssigneeID?: string;
|
||||
Status?: string;
|
||||
}
|
||||
|
||||
interface KanbanRow {
|
||||
Column?: string;
|
||||
Reminder?: { CreatedBy?: string };
|
||||
}
|
||||
|
||||
export function countApprovals(rows: unknown): number {
|
||||
return Array.isArray(rows) ? rows.length : 0;
|
||||
}
|
||||
|
||||
// /tasks liefert alle Aufgaben der Instanz; nur die eigenen, offenen werden gezaehlt.
|
||||
export function countOwnTasks(rows: unknown, userId: string): number {
|
||||
if (!Array.isArray(rows)) return 0;
|
||||
return (rows as TaskRow[]).filter(
|
||||
(t) => t.AssigneeType === "person" && t.AssigneeID === userId && t.Status !== "done"
|
||||
).length;
|
||||
}
|
||||
|
||||
// /reminders/kanban liefert alle Wiedervorlagen; gezaehlt werden die eigenen, ueberfaelligen oder diese Woche faelligen.
|
||||
export function countOwnReminders(rows: unknown, userId: string): number {
|
||||
if (!Array.isArray(rows)) return 0;
|
||||
return (rows as KanbanRow[]).filter(
|
||||
(k) => k.Reminder?.CreatedBy === userId && (k.Column === "overdue" || k.Column === "this_week")
|
||||
).length;
|
||||
}
|
||||
|
||||
export async function loadWorklist(
|
||||
base: string,
|
||||
tenant: string,
|
||||
userId: string,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<Worklist> {
|
||||
const root = base.replace(/\/$/, "");
|
||||
const headers = { "X-User-Id": userId, "X-Tenant-Slug": tenant, Accept: "application/json" };
|
||||
const get = async (path: string): Promise<unknown | null> => {
|
||||
try {
|
||||
const res = await fetchImpl(`${root}${path}`, { headers, cache: "no-store" });
|
||||
return res.ok ? await res.json() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const [approvals, tasks, reminders] = await Promise.all([get("/approvals"), get("/tasks"), get("/reminders/kanban")]);
|
||||
return {
|
||||
approvals: approvals === null ? null : countApprovals(approvals),
|
||||
tasks: tasks === null ? null : countOwnTasks(tasks, userId),
|
||||
remindersDue: reminders === null ? null : countOwnReminders(reminders, userId),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { AccountBar } from "../components/AccountBar";
|
||||
import { I18nProvider } from "../i18n/i18n";
|
||||
import { ThemeProvider } from "../theme/ThemeProvider";
|
||||
|
||||
function renderBar(props: Partial<React.ComponentProps<typeof AccountBar>>) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<AccountBar visible onLogout={() => {}} {...props} />
|
||||
</I18nProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("AccountBar", () => {
|
||||
it("zeigt Benutzer, Rolle und Mandant", async () => {
|
||||
renderBar({ loadIdentity: async () => ({ userId: "u-1", tenant: "t-1", role: "admin" }) });
|
||||
const el = await screen.findByTestId("account-identity");
|
||||
expect(el.textContent).toContain("u-1");
|
||||
expect(el.textContent).toContain("admin");
|
||||
expect(el.textContent).toContain("t-1");
|
||||
});
|
||||
|
||||
it("zeigt nur Abmelden, wenn die Identitaet nicht ladbar ist", async () => {
|
||||
renderBar({ loadIdentity: async () => { throw new Error("x"); } });
|
||||
await screen.findByRole("button");
|
||||
expect(screen.queryByTestId("account-identity")).toBeNull();
|
||||
});
|
||||
|
||||
it("rendert nichts, wenn nicht sichtbar", () => {
|
||||
const { container } = renderBar({ visible: false });
|
||||
expect(container.querySelector("header")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,8 @@ export interface Identity {
|
||||
role: string;
|
||||
}
|
||||
|
||||
export type IdentityLoader = () => Promise<Identity | null>;
|
||||
|
||||
export interface SessionOptions {
|
||||
coreBase: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { LogoutButton } from "../auth/LogoutButton";
|
||||
import type { Identity, IdentityLoader } from "../auth/session";
|
||||
import { useI18n } from "../i18n/i18n";
|
||||
|
||||
export interface AccountBarProps {
|
||||
visible: boolean;
|
||||
onLogout: () => void;
|
||||
loadIdentity?: IdentityLoader;
|
||||
}
|
||||
|
||||
export function AccountBar({ visible, onLogout, loadIdentity }: AccountBarProps) {
|
||||
const { t } = useI18n();
|
||||
const [identity, setIdentity] = useState<Identity | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !loadIdentity) return;
|
||||
let cancelled = false;
|
||||
loadIdentity().then(
|
||||
(id) => {
|
||||
if (!cancelled) setIdentity(id);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [visible, loadIdentity]);
|
||||
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<header style={{ display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 16, padding: "8px 16px" }}>
|
||||
{identity && (
|
||||
<span data-testid="account-identity">
|
||||
{identity.userId} · {t("shl.auth.role")}: {identity.role} · {t("shl.auth.tenant")}: {identity.tenant}
|
||||
</span>
|
||||
)}
|
||||
<LogoutButton onLogout={onLogout} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -17,8 +17,11 @@ import { useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Shell } from "./Shell";
|
||||
|
||||
export type ModuleGroup = "work" | "admin" | "audit";
|
||||
|
||||
export interface ModuleRegistryEntry {
|
||||
key: string;
|
||||
group?: ModuleGroup;
|
||||
label: string;
|
||||
href: string;
|
||||
description?: string;
|
||||
@@ -27,55 +30,55 @@ export interface ModuleRegistryEntry {
|
||||
|
||||
export const MODULE_REGISTRY: ModuleRegistryEntry[] = [
|
||||
{
|
||||
key: "dms", label: "Dokumente", href: "http://localhost:3101",
|
||||
key: "dms", group: "work", label: "Dokumente", href: "http://localhost:3101",
|
||||
description: "Dokumente durchsuchen und verwalten.", permission: "dms.document.read",
|
||||
},
|
||||
{
|
||||
key: "dms_upload", label: "Dokumente hochladen", href: "http://localhost:3107",
|
||||
key: "dms_upload", group: "work", label: "Dokumente hochladen", href: "http://localhost:3107",
|
||||
description: "Dokumente und Kassenbelege hochladen, inklusive mobiler Kamera-Erfassung.", permission: "dms.upload.read",
|
||||
},
|
||||
{
|
||||
key: "mail", label: "Postfach", href: "http://localhost:3102",
|
||||
key: "mail", group: "work", label: "Postfach", href: "http://localhost:3102",
|
||||
description: "E-Mails durchsuchen und verwalten.", permission: "mail.message.read",
|
||||
},
|
||||
{
|
||||
key: "workflow", label: "Aufgaben & Freigaben", href: "http://localhost:3103",
|
||||
key: "workflow", group: "work", label: "Aufgaben & Freigaben", href: "http://localhost:3103",
|
||||
description: "Freigaben bearbeiten, Akten einsehen, Aufgaben verwalten.", permission: "workflow.approval.read",
|
||||
},
|
||||
{
|
||||
key: "workflow_cases", label: "Akten & Vorgänge", href: "http://localhost:3108",
|
||||
key: "workflow_cases", group: "work", label: "Akten & Vorgänge", href: "http://localhost:3108",
|
||||
description: "Akten, Vermerke und Wiedervorlagen einsehen.", permission: "workflow.case.read",
|
||||
},
|
||||
{
|
||||
key: "routing_rules", label: "Routing-Regeln", href: "http://localhost:3109",
|
||||
key: "routing_rules", group: "admin", label: "Routing-Regeln", href: "http://localhost:3109",
|
||||
description: "Regelbasiertes Routing anlegen und testen.", permission: "workflow.routingrule.read",
|
||||
},
|
||||
{
|
||||
key: "ai", label: "KI-Funktionen", href: "http://localhost:3104",
|
||||
key: "ai", group: "work", label: "KI-Funktionen", href: "http://localhost:3104",
|
||||
description: "Zusammenfassungen, Extraktion und semantische Suche.", permission: "ai.summary.read",
|
||||
},
|
||||
{
|
||||
key: "ai_extraction", label: "Extraktions-Korrektur", href: "http://localhost:3110",
|
||||
key: "ai_extraction", group: "work", label: "Extraktions-Korrektur", href: "http://localhost:3110",
|
||||
description: "Von der KI extrahierte Werte prüfen und korrigieren.", permission: "ai.extraction.read",
|
||||
},
|
||||
{
|
||||
key: "connect", label: "Externe Anbindungen", href: "http://localhost:3105",
|
||||
key: "connect", group: "admin", label: "Externe Anbindungen", href: "http://localhost:3105",
|
||||
description: "Status externer Systemanbindungen (ERP, DATEV, CRM).", permission: "connect.status.read",
|
||||
},
|
||||
{
|
||||
key: "connect_datev", label: "DATEV-Export", href: "http://localhost:3111",
|
||||
key: "connect_datev", group: "admin", label: "DATEV-Export", href: "http://localhost:3111",
|
||||
description: "DATEV-Exporte anstoßen und Übertragungsprotokoll einsehen.", permission: "connect.datev.read",
|
||||
},
|
||||
{
|
||||
key: "tenant_admin", label: "Mandantenverwaltung", href: "http://localhost:3106",
|
||||
key: "tenant_admin", group: "admin", label: "Mandantenverwaltung", href: "http://localhost:3106",
|
||||
description: "Mandanten und Import-Verzeichnisse verwalten.", permission: "core.tenant.admin",
|
||||
},
|
||||
{
|
||||
key: "rbac_admin", label: "Rollen & Rechte", href: "http://localhost:3112",
|
||||
key: "rbac_admin", group: "admin", label: "Rollen & Rechte", href: "http://localhost:3112",
|
||||
description: "Rollen und Berechtigungen verwalten.", permission: "core.tenant.admin",
|
||||
},
|
||||
{
|
||||
key: "kennzahlen_dashboard", label: "Kennzahlen", href: "http://localhost:3113",
|
||||
key: "kennzahlen_dashboard", group: "audit", label: "Kennzahlen", href: "http://localhost:3113",
|
||||
description: "Löschkennzahlen je Tenant und Modul einsehen.", permission: "kennzahlen.read",
|
||||
},
|
||||
{ key: "portal", label: "Übersicht", href: "http://localhost:3900" },
|
||||
@@ -83,6 +86,7 @@ export const MODULE_REGISTRY: ModuleRegistryEntry[] = [
|
||||
|
||||
export interface ModuleNavShellProps {
|
||||
activeKey: string;
|
||||
tenantLabel?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -119,7 +123,7 @@ function tenantLabel(): string {
|
||||
return process.env.NEXT_PUBLIC_TENANT_SLUG ?? process.env.NEXT_PUBLIC_MAIL_TENANT_SLUG ?? "demo";
|
||||
}
|
||||
|
||||
export function ModuleNavShell({ activeKey, children }: ModuleNavShellProps) {
|
||||
export function ModuleNavShell({ activeKey, tenantLabel: tenantLabelProp, children }: ModuleNavShellProps) {
|
||||
const [hrefs, setHrefs] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -134,7 +138,7 @@ export function ModuleNavShell({ activeKey, children }: ModuleNavShellProps) {
|
||||
}));
|
||||
|
||||
return (
|
||||
<Shell modules={modules} tenantLabel={tenantLabel()}>
|
||||
<Shell modules={modules} tenantLabel={tenantLabelProp ?? tenantLabel()}>
|
||||
{children}
|
||||
</Shell>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,8 @@ const baseMessages: MessageBundle = {
|
||||
"shl.theme.toggleToLight": "Helles Erscheinungsbild",
|
||||
"shl.theme.toggleToDark": "Dunkles Erscheinungsbild",
|
||||
"shl.auth.logout": "Abmelden",
|
||||
"shl.auth.role": "Rolle",
|
||||
"shl.auth.tenant": "Mandant",
|
||||
},
|
||||
en: {
|
||||
"shl.dialog.close": "Close",
|
||||
@@ -27,6 +29,8 @@ const baseMessages: MessageBundle = {
|
||||
"shl.theme.toggleToLight": "Switch to light theme",
|
||||
"shl.theme.toggleToDark": "Switch to dark theme",
|
||||
"shl.auth.logout": "Sign out",
|
||||
"shl.auth.role": "Role",
|
||||
"shl.auth.tenant": "Tenant",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+5
-2
@@ -15,7 +15,10 @@ export { Shell } from "./components/Shell";
|
||||
export type { ShellProps, ModuleLink } from "./components/Shell";
|
||||
|
||||
export { ModuleNavShell, MODULE_REGISTRY, resolveModuleHref } from "./components/ModuleNavShell";
|
||||
export type { ModuleNavShellProps, ModuleRegistryEntry } from "./components/ModuleNavShell";
|
||||
export type { ModuleNavShellProps, ModuleRegistryEntry, ModuleGroup } from "./components/ModuleNavShell";
|
||||
|
||||
export { AccountBar } from "./components/AccountBar";
|
||||
export type { AccountBarProps } from "./components/AccountBar";
|
||||
|
||||
export { Dialog } from "./components/Dialog";
|
||||
export type { DialogProps } from "./components/Dialog";
|
||||
@@ -27,7 +30,7 @@ export { TextField, SelectField, CheckboxField } from "./components/FormElements
|
||||
export type { TextFieldProps, SelectFieldProps, CheckboxFieldProps } from "./components/FormElements";
|
||||
|
||||
export { createSession, redirectToLogin, describeHttpError, authMessages, AuthError } from "./auth/session";
|
||||
export type { Session, SessionOptions, Identity, AuthErrorKind } from "./auth/session";
|
||||
export type { Session, SessionOptions, Identity, IdentityLoader, AuthErrorKind } from "./auth/session";
|
||||
export { LogoutButton } from "./auth/LogoutButton";
|
||||
export type { LogoutButtonProps } from "./auth/LogoutButton";
|
||||
|
||||
|
||||
Reference in new issue
Block a user