DMS: Token-Rolle tenant_admin gilt im Guard als Admin (Core-Token-Modus); Smoke: Admin als Lebenszyklus-Akteur, Rollenpruefung R5
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-22 00:21:35 +02:00
1 parent e1a6a9b276
commit 403ceb6b61
8 files changed
+61 -5

No files matched your search

+1 -1
View File
@@ -133,7 +133,7 @@ func guardMux(next http.Handler, dsn, prefix, adminIDs string) (http.Handler, er
admins[id] = true
}
}
return authz.Guard(next, &authz.PoolChecker{Pool: pool, Admins: admins}, prefix), nil
return authz.Guard(next, &authz.PoolChecker{Pool: pool, Admins: admins, AdminRole: "tenant_admin"}, prefix), nil
}
// recoverMiddleware faengt Panics in Handlern ab und meldet sie zentral
+35
View File
@@ -0,0 +1,35 @@
package authz
import (
"context"
"testing"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/moduleauth"
)
func TestIsAdmin_RoleFromVerifiedTokenCountsAsAdmin(t *testing.T) {
c := &PoolChecker{Admins: map[string]bool{"listed": true}, AdminRole: "tenant_admin"}
ctx := func(id moduleauth.Identity) context.Context { return moduleauth.WithIdentity(context.Background(), id) }
tests := []struct {
name string
ctx context.Context
caller string
want bool
}{
{"in der Liste", context.Background(), "listed", true},
{"Rolle tenant_admin im Token", ctx(moduleauth.Identity{UserID: "u1", Role: "tenant_admin"}), "u1", true},
{"Rolle user im Token", ctx(moduleauth.Identity{UserID: "u1", Role: "user"}), "u1", false},
{"Rolle gehoert zu anderem Nutzer", ctx(moduleauth.Identity{UserID: "u2", Role: "tenant_admin"}), "u1", false},
{"ohne Token-Identitaet (Interimsakteur)", context.Background(), "u1", false},
}
for _, tc := range tests {
got, err := c.IsAdmin(tc.ctx, tc.caller)
if err != nil || got != tc.want {
t.Errorf("%s: got %v, %v; want %v", tc.name, got, err, tc.want)
}
}
noRole := &PoolChecker{}
if got, _ := noRole.IsAdmin(ctx(moduleauth.Identity{UserID: "u1", Role: "tenant_admin"}), "u1"); got {
t.Error("ohne AdminRole darf die Token-Rolle nicht wirken")
}
}
+13 -2
View File
@@ -15,6 +15,8 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/moduleauth"
)
type Checker interface {
@@ -27,6 +29,8 @@ type Checker interface {
type PoolChecker struct {
Pool *pgxpool.Pool
Admins map[string]bool
// AdminRole: Rolle im verifizierten Modul-Token, die wie ein Eintrag in Admins gilt.
AdminRole string
}
func (c *PoolChecker) owner(ctx context.Context, query, caller, id string) (bool, error) {
@@ -62,8 +66,15 @@ func (c *PoolChecker) OwnsUploadSession(ctx context.Context, caller, id string)
return c.owner(ctx, `SELECT created_by::text FROM upload_sessions WHERE id = $1`, caller, id)
}
func (c *PoolChecker) IsAdmin(_ context.Context, caller string) (bool, error) {
return c.Admins[caller], nil
func (c *PoolChecker) IsAdmin(ctx context.Context, caller string) (bool, error) {
if c.Admins[caller] {
return true, nil
}
if c.AdminRole != "" {
id, ok := moduleauth.IdentityFrom(ctx)
return ok && id.UserID == caller && id.Role == c.AdminRole, nil
}
return false, nil
}
func validUUID(s string) bool {
+4
View File
@@ -17,6 +17,10 @@ type Identity struct {
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
Binary file not shown.
+2 -2
View File
@@ -48,8 +48,8 @@ if [ "${GESAMT_TEARDOWN_ONLY:-}" = 1 ]; then teardown; echo "zurueckgebaut"; exi
set -a; source /etc/nexarch-dev/core-dev.env; source /etc/nexarch-dev/tenant-dev.env; set +a
case "$TEST_ADMIN_DSN$TEST_TENANT_DSN" in *:5433/*) ;; *) echo "DSN zeigt nicht auf den Wegwerf-Cluster (5433) - Abbruch"; exit 2 ;; esac
mkdir -p "$BASE"/{bin,env,data/storage,data/staging,log,corpus,gocache,gopath,results}
snapshot /tmp/gesamt-smoke-vorher.txt
mkdir -p "$BASE"/{bin,env,data/storage,data/staging,log,corpus,gocache,gopath,results}
finish() {
[ "${GESAMT_KEEP:-}" = 1 ] && return
cp "$RES"/*.txt /tmp/ 2>/dev/null
@@ -190,7 +190,7 @@ chown -R "$SVC_USER":"$SVC_USER" "$BASE/corpus"
export FDN19_API="http://127.0.0.1:8600/api/dms/v1" FDN19_ROOT="http://127.0.0.1:8600" FDN19_STUB="http://$STUB"
export FDN19_DSN="$TEST_TENANT_DSN" FDN19_TENANT=$TENANT FDN19_CORPUS="$BASE/corpus" FDN19_TERM=Zinnoberrot FDN19_TOOL="$BASE/bin/smoke-tool"
export FDN19_MIGRATIONS="$ROOT/dms/migrations/tenant" NEXARCH_DMS_APP_TENANT_DSN="$TEST_TENANT_DSN"
export FDN19_CORE_ACCOUNT=http://127.0.0.1:8099 FDN19_CORE_ACCOUNT_B=http://127.0.0.1:8098 FDN19_CORE_PASSWORD="$password" FDN19_CORE_USERS="alice@e2e.test,carol@e2e.test"
export FDN19_CORE_ACCOUNT=http://127.0.0.1:8099 FDN19_CORE_ACCOUNT_B=http://127.0.0.1:8098 FDN19_CORE_PASSWORD="$password" FDN19_CORE_USERS="bob@e2e.test,carol@e2e.test"
cd scripts || exit 2
measure() {
+6
View File
@@ -38,6 +38,12 @@ def roles():
f.check("R2 %s: user 403, tenant_admin erlaubt" % path, su == 403 and sa not in (401, 403), "user %s, admin %s" % (su, sa))
st_u = status("/trash/purge", user_tok, "POST")
f.check("R3 POST /trash/purge: user 403", st_u == 403, "status %s" % st_u)
uid = f.psql("SELECT id FROM users WHERE email = '%s'" % ALICE).split("\n")[0]
f.CORE_EMAIL[uid] = ALICE
u = f.Actor("core", uid)
doc = u.upload("user-rolle.txt", "text/plain", ("Rollenbeleg %d" % time.time_ns()).encode())
st, _ = u.call("GET", "/documents/%s" % doc)
f.check("R5 user (nur Standardrolle) laedt hoch und liest eigenes Dokument", st == 200, "status %s" % st)
st_none = f.http("GET", f.API + "/tags")[0]
f.check("R4 ohne Token 401", st_none == 401, "status %s" % st_none)