package policyapi import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "gitea.perlbach24.de/scripte/nexarch/internal/policy" ) func doJSON(t *testing.T, server *httptest.Server, method, path string, body any, token string) *http.Response { t.Helper() var reader *bytes.Reader if body != nil { b, err := json.Marshal(body) if err != nil { t.Fatal(err) } reader = bytes.NewReader(b) } else { reader = bytes.NewReader(nil) } req, err := http.NewRequest(method, server.URL+path, reader) if err != nil { t.Fatal(err) } if token != "" { req.Header.Set("X-Service-Token", token) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } return resp } // Akzeptanzkriterium 3: die Entscheidung ueber den bestehenden Policy- // Endpunkt liefert Objektrechte, ohne bestehende role/permission-Aufrufer // zu aendern (dieser Test ruft ausschliesslich den neuen Objektmodus auf). func TestObjectACLHandler_AuthorizeObjectMode(t *testing.T) { enforcer, flags := setupTest(t) mux := http.NewServeMux() Mount(mux, enforcer, flags, testToken) server := httptest.NewServer(mux) defer server.Close() const tenantSlug = "rbac11_http" // Ohne jeden ACL-Eintrag: Default-Deny. resp := doJSON(t, server, http.MethodPost, "/authorize", authorizeRequest{ Tenant: tenantSlug, Object: &objectQuery{ Path: []objectPathNode{{Type: "folder", ID: "f1"}}, Right: "read", UserID: "u1", }, }, testToken) var out authorizeResponse if resp.StatusCode != http.StatusOK { t.Fatalf("status: %d", resp.StatusCode) } _ = json.NewDecoder(resp.Body).Decode(&out) _ = resp.Body.Close() if out.Allowed || out.Reason != "object_denied" { t.Fatalf("default-deny erwartet, habe %+v", out) } // Verwaltungsendpunkt: Recht gewaehren. grantResp := doJSON(t, server, http.MethodPost, "/object-acl/grant", map[string]string{ "tenant": tenantSlug, "object_type": "folder", "object_id": "f1", "principal_type": "user", "principal_id": "u1", "right": "read", "effect": "allow", "actor": "admin", }, testToken) if grantResp.StatusCode != http.StatusNoContent { t.Fatalf("grant status: %d", grantResp.StatusCode) } _ = grantResp.Body.Close() resp = doJSON(t, server, http.MethodPost, "/authorize", authorizeRequest{ Tenant: tenantSlug, Object: &objectQuery{ Path: []objectPathNode{{Type: "folder", ID: "f1"}}, Right: "read", UserID: "u1", }, }, testToken) out = authorizeResponse{} _ = json.NewDecoder(resp.Body).Decode(&out) _ = resp.Body.Close() if !out.Allowed { t.Fatalf("nach grant erwartet allowed=true, habe %+v", out) } // Verwaltungsendpunkt: Liste zeigt den Eintrag. listResp := doJSON(t, server, http.MethodGet, "/object-acl/list?tenant="+tenantSlug+"&object_type=folder&object_id=f1", nil, testToken) var entries []policy.ObjectACLEntry _ = json.NewDecoder(listResp.Body).Decode(&entries) _ = listResp.Body.Close() if len(entries) != 1 { t.Fatalf("erwartet 1 eintrag, habe %d", len(entries)) } // Verwaltungsendpunkt: Unterbrechung setzen, dann Recht entziehen. breakResp := doJSON(t, server, http.MethodPost, "/object-acl/break", map[string]string{ "tenant": tenantSlug, "folder_id": "f1", "actor": "admin", }, testToken) if breakResp.StatusCode != http.StatusNoContent { t.Fatalf("break status: %d", breakResp.StatusCode) } _ = breakResp.Body.Close() revokeResp := doJSON(t, server, http.MethodPost, "/object-acl/revoke", map[string]string{ "tenant": tenantSlug, "object_type": "folder", "object_id": "f1", "principal_type": "user", "principal_id": "u1", "right": "read", "actor": "admin", }, testToken) if revokeResp.StatusCode != http.StatusNoContent { t.Fatalf("revoke status: %d", revokeResp.StatusCode) } _ = revokeResp.Body.Close() resp = doJSON(t, server, http.MethodPost, "/authorize", authorizeRequest{ Tenant: tenantSlug, Object: &objectQuery{Path: []objectPathNode{{Type: "folder", ID: "f1"}}, Right: "read", UserID: "u1"}, }, testToken) out = authorizeResponse{} _ = json.NewDecoder(resp.Body).Decode(&out) _ = resp.Body.Close() if out.Allowed { t.Fatalf("nach revoke erwartet allowed=false, habe %+v", out) } } // Bestehendes Verhalten bleibt unveraendert: ein Request ohne Object-Feld // nimmt weiterhin den alten role/permission-Pfad. func TestObjectACLHandler_RoleModeUnaffected(t *testing.T) { enforcer, flags := setupTest(t) mux := http.NewServeMux() Mount(mux, enforcer, flags, testToken) server := httptest.NewServer(mux) defer server.Close() status, allowed := authorize(t, server, "rbac06_erlaubte_rolle", "rbac06_recht") if status != http.StatusOK || !allowed { t.Fatalf("status=%d allowed=%v", status, allowed) } } // Authentifizierung und Validierung. func TestObjectACLHandler_AuthAndValidation(t *testing.T) { enforcer, flags := setupTest(t) mux := http.NewServeMux() Mount(mux, enforcer, flags, testToken) server := httptest.NewServer(mux) defer server.Close() // Ohne Service-Token. resp := doJSON(t, server, http.MethodPost, "/object-acl/grant", map[string]string{ "tenant": "rbac11_auth", "object_type": "folder", "object_id": "f1", "principal_type": "user", "principal_id": "u1", "right": "read", "actor": "admin", }, "") if resp.StatusCode != http.StatusUnauthorized { t.Errorf("ohne token: status=%d", resp.StatusCode) } _ = resp.Body.Close() // Objektanfrage ohne Tenant. resp = doJSON(t, server, http.MethodPost, "/authorize", authorizeRequest{ Object: &objectQuery{Path: []objectPathNode{{Type: "folder", ID: "f1"}}, Right: "read", UserID: "u1"}, }, testToken) if resp.StatusCode != http.StatusBadRequest { t.Errorf("objekt-anfrage ohne tenant: status=%d", resp.StatusCode) } _ = resp.Body.Close() // Leerer Pfad. resp = doJSON(t, server, http.MethodPost, "/authorize", authorizeRequest{ Tenant: "rbac11_auth", Object: &objectQuery{Right: "read", UserID: "u1"}, }, testToken) if resp.StatusCode != http.StatusBadRequest { t.Errorf("leerer pfad: status=%d", resp.StatusCode) } _ = resp.Body.Close() // Ungueltiger objekttyp beim Grant. resp = doJSON(t, server, http.MethodPost, "/object-acl/grant", map[string]string{ "tenant": "rbac11_auth", "object_type": "raum", "object_id": "f1", "principal_type": "user", "principal_id": "u1", "right": "read", "actor": "admin", }, testToken) if resp.StatusCode != http.StatusBadRequest { t.Errorf("ungueltiger objekttyp: status=%d", resp.StatusCode) } _ = resp.Body.Close() } // Fremder Mandant: ein per WithAllowedTenants gebundenes Service-Token // darf Objektrechte nur fuer die zugelassenen Mandanten anfragen/aendern. func TestObjectACLHandler_TenantNotAllowed(t *testing.T) { enforcer, flags := setupTest(t) mux := http.NewServeMux() Mount(mux, enforcer, flags, testToken, WithAllowedTenants([]string{"rbac11_erlaubt"})) server := httptest.NewServer(mux) defer server.Close() resp := doJSON(t, server, http.MethodPost, "/object-acl/grant", map[string]string{ "tenant": "rbac11_fremd", "object_type": "folder", "object_id": "f1", "principal_type": "user", "principal_id": "u1", "right": "read", "actor": "admin", }, testToken) if resp.StatusCode != http.StatusForbidden { t.Errorf("fremder mandant beim grant: status=%d", resp.StatusCode) } _ = resp.Body.Close() resp = doJSON(t, server, http.MethodPost, "/authorize", authorizeRequest{ Tenant: "rbac11_fremd", Object: &objectQuery{Path: []objectPathNode{{Type: "folder", ID: "f1"}}, Right: "read", UserID: "u1"}, }, testToken) if resp.StatusCode != http.StatusForbidden { t.Errorf("fremder mandant bei objektanfrage: status=%d", resp.StatusCode) } _ = resp.Body.Close() } // DOC-23: die HTTP-Antwort des Objektmodus enthaelt has_entries, damit ein // Aufrufer (z.B. DMS) "kein Eintrag" von "durch Eintrag verweigert" // unterscheiden kann. func TestObjectACLHandler_ResponseIncludesHasEntries(t *testing.T) { enforcer, flags := setupTest(t) mux := http.NewServeMux() Mount(mux, enforcer, flags, testToken) server := httptest.NewServer(mux) defer server.Close() const tenantSlug = "rbac11_hasentries" resp := doJSON(t, server, http.MethodPost, "/authorize", authorizeRequest{ Tenant: tenantSlug, Object: &objectQuery{Path: []objectPathNode{{Type: "folder", ID: "f-neu"}}, Right: "read", UserID: "u1"}, }, testToken) var out authorizeResponse _ = json.NewDecoder(resp.Body).Decode(&out) _ = resp.Body.Close() if out.HasEntries == nil || *out.HasEntries { t.Fatalf("erwartet has_entries=false ohne jeden eintrag, habe %+v", out) } grantResp := doJSON(t, server, http.MethodPost, "/object-acl/grant", map[string]string{ "tenant": tenantSlug, "object_type": "folder", "object_id": "f-neu", "principal_type": "user", "principal_id": "u1", "right": "read", "effect": "allow", "actor": "admin", }, testToken) _ = grantResp.Body.Close() resp = doJSON(t, server, http.MethodPost, "/authorize", authorizeRequest{ Tenant: tenantSlug, Object: &objectQuery{Path: []objectPathNode{{Type: "folder", ID: "f-neu"}}, Right: "read", UserID: "u1"}, }, testToken) out = authorizeResponse{} _ = json.NewDecoder(resp.Body).Decode(&out) _ = resp.Body.Close() if out.HasEntries == nil || !*out.HasEntries || !out.Allowed { t.Fatalf("erwartet has_entries=true, allowed=true nach grant, habe %+v", out) } // role/permission-Modus bleibt unveraendert: HasEntries wird dort nicht // gesetzt (nil), damit bestehende Aufrufer keine neue Pflichtfeld- // Erwartung bekommen. roleResp := doJSON(t, server, http.MethodPost, "/authorize", authorizeRequest{Role: "user", Permission: "dms.read"}, testToken) var roleOut authorizeResponse _ = json.NewDecoder(roleResp.Body).Decode(&roleOut) _ = roleResp.Body.Close() if roleOut.HasEntries != nil { t.Fatalf("erwartet has_entries unbesetzt im role/permission-modus, habe %+v", roleOut) } }