RET-08: ret-06-api auf rbac-06 migrieren

- internal/rbacclient: HTTP-Client fuer Core RBAC-06 (POST /authorize)
- retentionapi.RequireRole (Header-Provisorium) ersetzt durch
  RequireRBAC, echter Aufruf gegen RBAC-06, fail-closed bei Fehlern
- Mount nimmt jetzt rbacclient.Client entgegen
- alle bestehenden RET-06-API-Tests weiterhin gruen
- neue Tests: verweigerte Rolle (403 gegen echte RBAC-06-Antwort),
  erlaubte Rolle (200), RBAC-06 nicht erreichbar -> fail-closed (403)
- real deployed auf 131, end-zu-ende per curl nachgewiesen (403/403/200/403)

Pruefungen siehe archive/docs/RET-08-PRUEFPROTOKOLL.md
This commit is contained in:
sysops
2026-08-30 08:43:00 +02:00
parent 21278f1405
commit b30e16cb4f
7 changed files with 288 additions and 55 deletions
+82
View File
@@ -0,0 +1,82 @@
// Package rbacclient ist ein schlanker HTTP-Client für Core RBAC-06
// (internal/policyapi, POST /authorize). Archive ist ein physisch
// getrenntes Go-Modul und kann Core RBAC-02 (internal/policy) daher
// nicht direkt importieren — nur über die HTTP-Schnittstelle von
// RBAC-06 aufrufen.
package rbacclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
)
// ErrDenied wird zurückgegeben, wenn RBAC-06 die Anfrage ausdrücklich
// ablehnt (kein Transport-/Serverfehler, sondern eine bewusste
// Policy-Entscheidung).
var ErrDenied = errors.New("rbac: zugriff verweigert")
// Client ruft den RBAC-06-Endpunkt auf.
type Client struct {
BaseURL string
ServiceToken string
HTTPClient *http.Client
}
func New(baseURL, serviceToken string) *Client {
return &Client{BaseURL: baseURL, ServiceToken: serviceToken, HTTPClient: http.DefaultClient}
}
type authorizeRequest struct {
Role string `json:"role"`
Permission string `json:"permission"`
}
type authorizeResponse struct {
Allowed bool `json:"allowed"`
}
// Authorize fragt RBAC-06, ob role die permission besitzt. Jeder
// Fehler (Transport, Timeout, unerwarteter Status) wird als Fehler
// zurückgegeben, NICHT stillschweigend als "erlaubt" behandelt — der
// Aufrufer muss bei einem Fehler fail-closed (verweigern) reagieren.
func (c *Client) Authorize(ctx context.Context, role, permission string) (bool, error) {
body, err := json.Marshal(authorizeRequest{Role: role, Permission: permission})
if err != nil {
return false, fmt.Errorf("rbac: request kodieren: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/authorize", bytes.NewReader(body))
if err != nil {
return false, fmt.Errorf("rbac: request bauen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Service-Token", c.ServiceToken)
resp, err := c.httpClient().Do(req)
if err != nil {
return false, fmt.Errorf("rbac: aufruf fehlgeschlagen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("rbac: unerwarteter status %d", resp.StatusCode)
}
var out authorizeResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return false, fmt.Errorf("rbac: antwort dekodieren: %w", err)
}
if !out.Allowed {
return false, ErrDenied
}
return true, nil
}
func (c *Client) httpClient() *http.Client {
if c.HTTPClient != nil {
return c.HTTPClient
}
return http.DefaultClient
}
+32 -30
View File
@@ -6,43 +6,45 @@
package retentionapi
import (
"errors"
"net/http"
"strings"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/rbacclient"
)
// requiredRoleHeader ist der Header-Name des PROVISORISCHEN Rollen-
// Checks (siehe RequireRole-Dokumentation).
const requiredRoleHeader = "X-Admin-Roles"
// requestedRoleHeader trägt die vom Aufrufer beanspruchte Rolle. Die
// eigentliche Autorisierungsentscheidung trifft NICHT dieser Header,
// sondern Core RBAC-06 (siehe RequireRBAC) — der Header ist nur die
// Behauptung, RBAC-06 prüft sie gegen die echten Policy-Rules.
const requestedRoleHeader = "X-User-Role"
// RequireRole ist ein PROVISORISCHER Rollen-Check, KEIN RBAC-02-Aufruf.
//
// Core RBAC-02 (internal/policy, Enforcer.Authorize) ist reiner
// Go-Code innerhalb des Core-Moduls, hat keine HTTP-Schnittstelle, über
// die Archive (physisch getrenntes Go-Modul) es aufrufen könnte —
// derselbe "gefunden, aber nicht modulübergreifend verdrahtet"-Befund
// wie bei Core FDN-03/FDN-09. Bis ein Core-seitiger HTTP-Wrapper um
// RBAC-02 existiert (eigenes, künftiges Core-Ticket, z. B. RBAC-06),
// prüft dieser Middleware NUR einen einfachen, selbst gesetzten Header
// (`X-Admin-Roles`, kommagetrennt) auf das Vorhandensein der
// geforderten Rolle — KEINE echte Autorisierung gegen Core, leicht zu
// fälschen von jedem, der den Header selbst setzen kann. Muss ersetzt
// werden, sobald der Core-HTTP-Wrapper existiert.
func RequireRole(requiredRole string, next http.HandlerFunc) http.HandlerFunc {
// RequireRBAC ist RET-08: die Ablösung des früheren, header-basierten
// Provisoriums (siehe archive/docs/RET-06-API-PRUEFPROTOKOLL.md) durch
// einen echten Aufruf von Core RBAC-06 (POST /authorize). Fail-closed:
// jeder Fehler beim Aufruf von RBAC-06 (Transport, Timeout, Ablehnung)
// führt zur Verweigerung, niemals zu stillschweigendem Zugriff.
func RequireRBAC(client *rbacclient.Client, permission string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
roles := r.Header.Get(requiredRoleHeader)
if !containsRole(roles, requiredRole) {
http.Error(w, "zugriff verweigert: rolle "+requiredRole+" erforderlich (provisorischer check, siehe RequireRole)", http.StatusForbidden)
role := r.Header.Get(requestedRoleHeader)
if role == "" {
http.Error(w, "zugriff verweigert: header "+requestedRoleHeader+" erforderlich", http.StatusForbidden)
return
}
allowed, err := client.Authorize(r.Context(), role, permission)
if err != nil {
if errors.Is(err, rbacclient.ErrDenied) {
http.Error(w, "zugriff verweigert: rolle "+role+" hat kein recht "+permission, http.StatusForbidden)
return
}
// RBAC-06 nicht erreichbar oder fehlerhafte Antwort:
// fail-closed, kein Fallback auf offenen Zugriff.
http.Error(w, "zugriff verweigert: autorisierungsdienst nicht erreichbar", http.StatusForbidden)
return
}
if !allowed {
http.Error(w, "zugriff verweigert: rolle "+role+" hat kein recht "+permission, http.StatusForbidden)
return
}
next(w, r)
}
}
func containsRole(commaSeparated, role string) bool {
for _, r := range strings.Split(commaSeparated, ",") {
if strings.TrimSpace(r) == role {
return true
}
}
return false
}
+77 -12
View File
@@ -1,22 +1,57 @@
package retentionapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/rbacclient"
)
// TestRequireRole_MissingRoleReturns403 ist die vom Nutzer geforderte
// Negativpruefung fuer den provisorischen Rollen-Check: keine/falsche
// Rolle => 403, sonst waere der Check nicht pruefbar.
func TestRequireRole_MissingRoleReturns403(t *testing.T) {
handler := RequireRole(adminRole, func(w http.ResponseWriter, r *http.Request) {
const testServiceToken = "test-service-token"
// fakeRBACServer simuliert Core RBAC-06 (POST /authorize): erlaubt nur
// die genau angegebene role/permission-Kombination, verlangt den
// Service-Token. Damit werden RequireRBAC-Aufrufe real über HTTP
// getestet, ohne echtes Core-Modul.
func fakeRBACServer(t *testing.T, allowRole, allowPermission string) *rbacclient.Client {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("POST /authorize", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Service-Token") != testServiceToken {
http.Error(w, "kein service-token", http.StatusUnauthorized)
return
}
var req struct {
Role string `json:"role"`
Permission string `json:"permission"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
allowed := req.Role == allowRole && req.Permission == allowPermission
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]bool{"allowed": allowed})
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return rbacclient.New(server.URL, testServiceToken)
}
// TestRequireRBAC_DeniedRoleReturns403 ist die vom Nutzer geforderte
// Negativpruefung, jetzt gegen echte Policy-Rules ueber RBAC-06 (nicht
// mehr nur ein Header-Wert wie im frueheren Provisorium).
func TestRequireRBAC_DeniedRoleReturns403(t *testing.T) {
client := fakeRBACServer(t, "archive_admin", "retention.configure")
handler := RequireRBAC(client, "retention.configure", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
server := httptest.NewServer(handler)
defer server.Close()
// Kein Header gesetzt.
// Kein Rollen-Header.
resp, err := http.Get(server.URL)
if err != nil {
t.Fatalf("get: %v", err)
@@ -26,9 +61,9 @@ func TestRequireRole_MissingRoleReturns403(t *testing.T) {
t.Fatalf("ohne rollen-header: status = %d, want 403", resp.StatusCode)
}
// Falsche Rolle gesetzt.
// Falsche Rolle - RBAC-06 lehnt real ab.
req, _ := http.NewRequest(http.MethodGet, server.URL, nil)
req.Header.Set("X-Admin-Roles", "irgendwas_anderes")
req.Header.Set(requestedRoleHeader, "irgendwas_anderes")
resp2, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get: %v", err)
@@ -39,16 +74,18 @@ func TestRequireRole_MissingRoleReturns403(t *testing.T) {
}
}
// TestRequireRole_CorrectRoleAllowsAccess ist der positive Gegentest.
func TestRequireRole_CorrectRoleAllowsAccess(t *testing.T) {
handler := RequireRole(adminRole, func(w http.ResponseWriter, r *http.Request) {
// TestRequireRBAC_AllowedRoleGrantsAccess ist der positive Gegentest,
// real gegen die fake-RBAC-06-Antwort geprueft.
func TestRequireRBAC_AllowedRoleGrantsAccess(t *testing.T) {
client := fakeRBACServer(t, "archive_admin", "retention.configure")
handler := RequireRBAC(client, "retention.configure", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
server := httptest.NewServer(handler)
defer server.Close()
req, _ := http.NewRequest(http.MethodGet, server.URL, nil)
req.Header.Set("X-Admin-Roles", "irgendwas_anderes, archive_admin")
req.Header.Set(requestedRoleHeader, "archive_admin")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get: %v", err)
@@ -58,3 +95,31 @@ func TestRequireRole_CorrectRoleAllowsAccess(t *testing.T) {
t.Fatalf("mit korrekter rolle: status = %d, want 200", resp.StatusCode)
}
}
// TestRequireRBAC_UnreachableRBACFailsClosed ist die geforderte
// Pflichtpruefung: Ausfall von RBAC-06 fuehrt zur Ablehnung, kein
// Fallback auf offenen Zugriff.
func TestRequireRBAC_UnreachableRBACFailsClosed(t *testing.T) {
unreachable := rbacclient.New("http://127.0.0.1:1", testServiceToken)
called := false
handler := RequireRBAC(unreachable, "retention.configure", func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
server := httptest.NewServer(handler)
defer server.Close()
req, _ := http.NewRequest(http.MethodGet, server.URL, nil)
req.Header.Set(requestedRoleHeader, "archive_admin")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("bei nicht erreichbarem rbac-06: status = %d, want 403 (fail-closed)", resp.StatusCode)
}
if called {
t.Fatal("handler haette bei nicht erreichbarem rbac-06 nie erreicht werden duerfen")
}
}
+11 -8
View File
@@ -7,19 +7,22 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/rbacclient"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionengine"
)
const adminRole = "archive_admin"
// retentionConfigurePermission ist das bei Core RBAC-02/RBAC-06
// geprüfte Recht für alle RET-06-API-Endpunkte.
const retentionConfigurePermission = "retention.configure"
// Mount registriert alle RET-06-API-Endpunkte auf mux, jeweils hinter
// dem provisorischen Rollen-Check (siehe authz.go) — Akzeptanzkriterium
// 3: Änderungen an Fristen sind nur berechtigten Rollen zugänglich.
func Mount(mux *http.ServeMux, pool *pgxpool.Pool) {
mux.HandleFunc("POST /retention-classes", RequireRole(adminRole, configureHandler(pool)))
mux.HandleFunc("POST /retention-classes/{class}/deactivate", RequireRole(adminRole, deactivateHandler(pool)))
mux.HandleFunc("GET /retention-classes", RequireRole(adminRole, listHandler(pool)))
mux.HandleFunc("GET /retention-classes/preview", RequireRole(adminRole, previewHandler(pool)))
// RequireRBAC (RET-08, echter RBAC-06-Aufruf) — Akzeptanzkriterium 3:
// Änderungen an Fristen sind nur berechtigten Rollen zugänglich.
func Mount(mux *http.ServeMux, pool *pgxpool.Pool, rbac *rbacclient.Client) {
mux.HandleFunc("POST /retention-classes", RequireRBAC(rbac, retentionConfigurePermission, configureHandler(pool)))
mux.HandleFunc("POST /retention-classes/{class}/deactivate", RequireRBAC(rbac, retentionConfigurePermission, deactivateHandler(pool)))
mux.HandleFunc("GET /retention-classes", RequireRBAC(rbac, retentionConfigurePermission, listHandler(pool)))
mux.HandleFunc("GET /retention-classes/preview", RequireRBAC(rbac, retentionConfigurePermission, previewHandler(pool)))
}
type configureRequest struct {
@@ -67,7 +67,7 @@ func authedRequest(t *testing.T, method, url string, body []byte) *http.Response
if err != nil {
t.Fatalf("request erstellen: %v", err)
}
req.Header.Set("X-Admin-Roles", adminRole)
req.Header.Set(requestedRoleHeader, "archive_admin")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request senden: %v", err)
@@ -80,7 +80,7 @@ func authedRequest(t *testing.T, method, url string, body []byte) *http.Response
func TestConfigureAndListHandler_RealHTTPRoundTrip(t *testing.T) {
pool := requireTestPool(t)
mux := http.NewServeMux()
Mount(mux, pool)
Mount(mux, pool, fakeRBACServer(t, "archive_admin", retentionConfigurePermission))
server := httptest.NewServer(mux)
defer server.Close()
@@ -110,7 +110,7 @@ func TestDeactivateHandler_RealHTTPRoundTrip(t *testing.T) {
t.Fatal(err)
}
mux := http.NewServeMux()
Mount(mux, pool)
Mount(mux, pool, fakeRBACServer(t, "archive_admin", retentionConfigurePermission))
server := httptest.NewServer(mux)
defer server.Close()
@@ -149,7 +149,7 @@ func TestPreviewHandler_MatchesPeriodicJobResult(t *testing.T) {
}
mux := http.NewServeMux()
Mount(mux, pool)
Mount(mux, pool, fakeRBACServer(t, "archive_admin", retentionConfigurePermission))
server := httptest.NewServer(mux)
defer server.Close()