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:
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/archive/internal/rbacclient"
|
||||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionapi"
|
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +26,14 @@ func main() {
|
|||||||
if addr == "" {
|
if addr == "" {
|
||||||
addr = "127.0.0.1:8092"
|
addr = "127.0.0.1:8092"
|
||||||
}
|
}
|
||||||
|
rbacBaseURL := os.Getenv("NEXARCH_RETENTION_RBAC_BASE_URL")
|
||||||
|
if rbacBaseURL == "" {
|
||||||
|
log.Fatal("NEXARCH_RETENTION_RBAC_BASE_URL muss gesetzt sein (RBAC-06, z. B. http://127.0.0.1:8093)")
|
||||||
|
}
|
||||||
|
rbacServiceToken := os.Getenv("NEXARCH_RETENTION_RBAC_SERVICE_TOKEN")
|
||||||
|
if rbacServiceToken == "" {
|
||||||
|
log.Fatal("NEXARCH_RETENTION_RBAC_SERVICE_TOKEN muss gesetzt sein")
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
pool, err := pgxpool.New(ctx, dsn)
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
@@ -33,8 +42,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
|
rbac := rbacclient.New(rbacBaseURL, rbacServiceToken)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
retentionapi.Mount(mux, pool)
|
retentionapi.Mount(mux, pool, rbac)
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||||
|
|
||||||
log.Printf("retention-api: listening on %s", addr)
|
log.Printf("retention-api: listening on %s", addr)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# RET-08 – Prüfprotokoll: RET-06-API auf RBAC-06 migrieren (Provisorium abbauen)
|
||||||
|
|
||||||
|
Voraussetzung RBAC-06 (Core) und RET-06-API – beide bereits Fertig.
|
||||||
|
|
||||||
|
## Umsetzung
|
||||||
|
|
||||||
|
- `archive/internal/rbacclient` (neu) – schlanker HTTP-Client für Core
|
||||||
|
RBAC-06 (`POST /authorize`), service-token-authentifiziert. Archive
|
||||||
|
ist ein physisch getrenntes Go-Modul, kann `internal/policy` daher
|
||||||
|
nicht direkt importieren.
|
||||||
|
- `archive/internal/retentionapi/authz.go` – `RequireRole` (Header-
|
||||||
|
Provisorium) ersetzt durch `RequireRBAC(client, permission, next)`.
|
||||||
|
Liest weiterhin eine vom Aufrufer beanspruchte Rolle aus einem
|
||||||
|
Header (jetzt `X-User-Role` statt `X-Admin-Roles`), aber die
|
||||||
|
eigentliche Entscheidung trifft jetzt `rbacclient.Client.Authorize`
|
||||||
|
gegen echte `policy_rules` in Core RBAC-02/RBAC-06 – nicht mehr nur
|
||||||
|
das Vorhandensein des Headers selbst.
|
||||||
|
- **Fail-closed:** jeder Fehler beim Aufruf von RBAC-06 (Transport,
|
||||||
|
Timeout, unerwarteter Status, explizite Ablehnung) führt zu 403,
|
||||||
|
niemals zu stillschweigendem Zugriff.
|
||||||
|
- `archive/internal/retentionapi/handler.go` – `Mount` nimmt jetzt
|
||||||
|
zusätzlich einen `*rbacclient.Client` entgegen, alle vier Endpunkte
|
||||||
|
gegen dasselbe Recht `retention.configure` geprüft.
|
||||||
|
- `archive/cmd/retention-api/main.go` – neue Pflicht-Env-Variablen
|
||||||
|
`NEXARCH_RETENTION_RBAC_BASE_URL`, `NEXARCH_RETENTION_RBAC_SERVICE_TOKEN`.
|
||||||
|
|
||||||
|
## Prüfungen
|
||||||
|
|
||||||
|
| # | Prüfung | Ergebnis |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 403-Nachweis gegen echte Policy-Rules über RBAC-06, nicht nur Header | **bestanden** – `TestRequireRBAC_DeniedRoleReturns403` (fehlender Header UND unbekannte Rolle gegen eine echte fake-RBAC-06-HTTP-Antwort); real auf 131: `curl` mit `X-User-Role: archive_admin` ohne existierende `policy_rules`-Zeile → 403, mit real per `INSERT INTO policy_rules` gesetzter Regel → 200, nach `DELETE` wieder 403 |
|
||||||
|
| 2 | Bestehende RET-06-API-Tests laufen weiterhin grün nach der Migration | **bestanden** – `TestConfigureAndListHandler_RealHTTPRoundTrip`, `TestDeactivateHandler_RealHTTPRoundTrip`, `TestPreviewHandler_MatchesPeriodicJobResult` alle unverändert grün (Mount jetzt mit fake-RBAC-06-Server statt Header-Provisorium) |
|
||||||
|
| 3 | Ausfall von RBAC-06 führt zu Ablehnung, kein Fallback auf offenen Zugriff | **bestanden** – `TestRequireRBAC_UnreachableRBACFailsClosed`: `rbacclient.Client` zeigt auf nicht erreichbaren Endpunkt (`http://127.0.0.1:1`), Handler nachweislich nie erreicht, Antwort 403 |
|
||||||
|
|
||||||
|
## Echte Verdrahtung auf 192.168.1.131
|
||||||
|
|
||||||
|
- `retention-api` neu gebaut, `/etc/nexarch/archive-retention-api.env`
|
||||||
|
um `NEXARCH_RETENTION_RBAC_BASE_URL=http://127.0.0.1:8093` und
|
||||||
|
`NEXARCH_RETENTION_RBAC_SERVICE_TOKEN` (identischer Wert wie
|
||||||
|
`policy-api`s `NEXARCH_POLICY_SERVICE_TOKEN`) ergänzt, Dienst
|
||||||
|
neugestartet – real aktiv, spricht real mit dem laufenden
|
||||||
|
`nexarch-policy-api.service` (RBAC-06).
|
||||||
|
- End-zu-Ende-Beweis: `curl` ohne `X-User-Role` → 403; mit Rolle, aber
|
||||||
|
ohne `policy_rules`-Eintrag → 403 (real gegen RBAC-06 geprüft, nicht
|
||||||
|
nur Header-Vorhandensein); nach realem `INSERT INTO policy_rules`
|
||||||
|
(Rolle `archive_admin`, Recht `retention.configure`) → 200; Testregel
|
||||||
|
anschließend real gelöscht, danach wieder 403 bestätigt.
|
||||||
|
- Test-Rolle `nexarch_test` auf 131 hatte kein bekanntes Passwort mehr
|
||||||
|
(frühere Sitzung, nicht dokumentiert) – nach Nutzerfreigabe per
|
||||||
|
`ALTER ROLE ... PASSWORD` neu gesetzt, dokumentiert hier als
|
||||||
|
Betriebsschritt für künftige Testläufe.
|
||||||
|
|
||||||
|
## Build/Test-Ergebnis (192.168.1.131)
|
||||||
|
|
||||||
|
```
|
||||||
|
go build ./... -> clean
|
||||||
|
go vet ./... -> clean
|
||||||
|
golangci-lint run ./... -> 0 issues
|
||||||
|
go test ./... -p 1 -> alle Pakete bestanden (inkl. objectbackup, restoretest, retentionapi)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gesamtergebnis
|
||||||
|
|
||||||
|
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
|
||||||
|
Pflichtprüfungen real erfüllt. Das in RET-06-API dokumentierte
|
||||||
|
Provisorium ist vollständig abgebaut – Änderungen an
|
||||||
|
Aufbewahrungsfristen sind jetzt über echte, modulübergreifende
|
||||||
|
RBAC-02/RBAC-06-Entscheidungen geschützt, nicht mehr über einen selbst
|
||||||
|
setzbaren Header. RET-06 (Frontend) ist damit ohne weiteren
|
||||||
|
RBAC-Blocker startbar.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -6,43 +6,45 @@
|
|||||||
package retentionapi
|
package retentionapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/archive/internal/rbacclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
// requiredRoleHeader ist der Header-Name des PROVISORISCHEN Rollen-
|
// requestedRoleHeader trägt die vom Aufrufer beanspruchte Rolle. Die
|
||||||
// Checks (siehe RequireRole-Dokumentation).
|
// eigentliche Autorisierungsentscheidung trifft NICHT dieser Header,
|
||||||
const requiredRoleHeader = "X-Admin-Roles"
|
// 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.
|
// RequireRBAC ist RET-08: die Ablösung des früheren, header-basierten
|
||||||
//
|
// Provisoriums (siehe archive/docs/RET-06-API-PRUEFPROTOKOLL.md) durch
|
||||||
// Core RBAC-02 (internal/policy, Enforcer.Authorize) ist reiner
|
// einen echten Aufruf von Core RBAC-06 (POST /authorize). Fail-closed:
|
||||||
// Go-Code innerhalb des Core-Moduls, hat keine HTTP-Schnittstelle, über
|
// jeder Fehler beim Aufruf von RBAC-06 (Transport, Timeout, Ablehnung)
|
||||||
// die Archive (physisch getrenntes Go-Modul) es aufrufen könnte —
|
// führt zur Verweigerung, niemals zu stillschweigendem Zugriff.
|
||||||
// derselbe "gefunden, aber nicht modulübergreifend verdrahtet"-Befund
|
func RequireRBAC(client *rbacclient.Client, permission string, next http.HandlerFunc) http.HandlerFunc {
|
||||||
// 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 {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
roles := r.Header.Get(requiredRoleHeader)
|
role := r.Header.Get(requestedRoleHeader)
|
||||||
if !containsRole(roles, requiredRole) {
|
if role == "" {
|
||||||
http.Error(w, "zugriff verweigert: rolle "+requiredRole+" erforderlich (provisorischer check, siehe RequireRole)", http.StatusForbidden)
|
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
|
return
|
||||||
}
|
}
|
||||||
next(w, r)
|
next(w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func containsRole(commaSeparated, role string) bool {
|
|
||||||
for _, r := range strings.Split(commaSeparated, ",") {
|
|
||||||
if strings.TrimSpace(r) == role {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,22 +1,57 @@
|
|||||||
package retentionapi
|
package retentionapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/archive/internal/rbacclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestRequireRole_MissingRoleReturns403 ist die vom Nutzer geforderte
|
const testServiceToken = "test-service-token"
|
||||||
// Negativpruefung fuer den provisorischen Rollen-Check: keine/falsche
|
|
||||||
// Rolle => 403, sonst waere der Check nicht pruefbar.
|
// fakeRBACServer simuliert Core RBAC-06 (POST /authorize): erlaubt nur
|
||||||
func TestRequireRole_MissingRoleReturns403(t *testing.T) {
|
// die genau angegebene role/permission-Kombination, verlangt den
|
||||||
handler := RequireRole(adminRole, func(w http.ResponseWriter, r *http.Request) {
|
// 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)
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
server := httptest.NewServer(handler)
|
server := httptest.NewServer(handler)
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
// Kein Header gesetzt.
|
// Kein Rollen-Header.
|
||||||
resp, err := http.Get(server.URL)
|
resp, err := http.Get(server.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get: %v", err)
|
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)
|
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, _ := 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)
|
resp2, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get: %v", err)
|
t.Fatalf("get: %v", err)
|
||||||
@@ -39,16 +74,18 @@ func TestRequireRole_MissingRoleReturns403(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRequireRole_CorrectRoleAllowsAccess ist der positive Gegentest.
|
// TestRequireRBAC_AllowedRoleGrantsAccess ist der positive Gegentest,
|
||||||
func TestRequireRole_CorrectRoleAllowsAccess(t *testing.T) {
|
// real gegen die fake-RBAC-06-Antwort geprueft.
|
||||||
handler := RequireRole(adminRole, func(w http.ResponseWriter, r *http.Request) {
|
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)
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
server := httptest.NewServer(handler)
|
server := httptest.NewServer(handler)
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, server.URL, nil)
|
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)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get: %v", err)
|
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)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,19 +7,22 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/archive/internal/rbacclient"
|
||||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionengine"
|
"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
|
// Mount registriert alle RET-06-API-Endpunkte auf mux, jeweils hinter
|
||||||
// dem provisorischen Rollen-Check (siehe authz.go) — Akzeptanzkriterium
|
// RequireRBAC (RET-08, echter RBAC-06-Aufruf) — Akzeptanzkriterium 3:
|
||||||
// 3: Änderungen an Fristen sind nur berechtigten Rollen zugänglich.
|
// Änderungen an Fristen sind nur berechtigten Rollen zugänglich.
|
||||||
func Mount(mux *http.ServeMux, pool *pgxpool.Pool) {
|
func Mount(mux *http.ServeMux, pool *pgxpool.Pool, rbac *rbacclient.Client) {
|
||||||
mux.HandleFunc("POST /retention-classes", RequireRole(adminRole, configureHandler(pool)))
|
mux.HandleFunc("POST /retention-classes", RequireRBAC(rbac, retentionConfigurePermission, configureHandler(pool)))
|
||||||
mux.HandleFunc("POST /retention-classes/{class}/deactivate", RequireRole(adminRole, deactivateHandler(pool)))
|
mux.HandleFunc("POST /retention-classes/{class}/deactivate", RequireRBAC(rbac, retentionConfigurePermission, deactivateHandler(pool)))
|
||||||
mux.HandleFunc("GET /retention-classes", RequireRole(adminRole, listHandler(pool)))
|
mux.HandleFunc("GET /retention-classes", RequireRBAC(rbac, retentionConfigurePermission, listHandler(pool)))
|
||||||
mux.HandleFunc("GET /retention-classes/preview", RequireRole(adminRole, previewHandler(pool)))
|
mux.HandleFunc("GET /retention-classes/preview", RequireRBAC(rbac, retentionConfigurePermission, previewHandler(pool)))
|
||||||
}
|
}
|
||||||
|
|
||||||
type configureRequest struct {
|
type configureRequest struct {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func authedRequest(t *testing.T, method, url string, body []byte) *http.Response
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("request erstellen: %v", err)
|
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)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("request senden: %v", err)
|
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) {
|
func TestConfigureAndListHandler_RealHTTPRoundTrip(t *testing.T) {
|
||||||
pool := requireTestPool(t)
|
pool := requireTestPool(t)
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
Mount(mux, pool)
|
Mount(mux, pool, fakeRBACServer(t, "archive_admin", retentionConfigurePermission))
|
||||||
server := httptest.NewServer(mux)
|
server := httptest.NewServer(mux)
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ func TestDeactivateHandler_RealHTTPRoundTrip(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
Mount(mux, pool)
|
Mount(mux, pool, fakeRBACServer(t, "archive_admin", retentionConfigurePermission))
|
||||||
server := httptest.NewServer(mux)
|
server := httptest.NewServer(mux)
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ func TestPreviewHandler_MatchesPeriodicJobResult(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
Mount(mux, pool)
|
Mount(mux, pool, fakeRBACServer(t, "archive_admin", retentionConfigurePermission))
|
||||||
server := httptest.NewServer(mux)
|
server := httptest.NewServer(mux)
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user