Files
nexarch/internal/policyapi/handler.go
patrickandClaude Sonnet 5 457346ccd1
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
DOC-23: rbac-11-objektrechte im dms verdrahten
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 12:50:07 +02:00

351 lines
12 KiB
Go

package policyapi
import (
"encoding/json"
"errors"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
"gitea.perlbach24.de/scripte/nexarch/internal/policy"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
)
// Option konfiguriert Mount (RBAC-10).
type Option func(*server)
// WithAllowedTenants bindet das Service-Token an eine Mandantenliste: ein
// Request fuer einen anderen Mandanten erhaelt 403. Ohne Option ist jeder
// gueltige Mandant zulaessig.
func WithAllowedTenants(slugs []string) Option {
return func(s *server) {
s.allowed = map[string]bool{}
for _, slug := range slugs {
s.allowed[slug] = true
}
}
}
type server struct {
enforcer *policy.Enforcer
flags *flag.Service
allowed map[string]bool
}
// Mount registriert den Autorisierungs-Endpunkt auf mux, hinter dem
// Service-Token-Check (Akzeptanzkriterium 2).
func Mount(mux *http.ServeMux, enforcer *policy.Enforcer, flags *flag.Service, serviceToken string, opts ...Option) {
s := &server{enforcer: enforcer, flags: flags}
for _, o := range opts {
o(s)
}
mux.HandleFunc("POST /authorize", RequireServiceToken(serviceToken, s.authorize))
// RBAC-11: Verwaltungsschnittstelle fuer Objektrechte (Akzeptanzkriterium
// 1). Dieselbe Authentifizierung wie /authorize — object_acl liegt in
// derselben Registry-DB, ein eigener Dienst/eine Session-Auth waere hier
// unnoetige Duplizierung (RBAC-05, die Rechteverwaltungsoberflaeche
// selbst, ist ausdruecklich nicht Bestandteil dieser Kachel).
mux.HandleFunc("POST /object-acl/grant", RequireServiceToken(serviceToken, s.objectACLGrant))
mux.HandleFunc("POST /object-acl/revoke", RequireServiceToken(serviceToken, s.objectACLRevoke))
mux.HandleFunc("POST /object-acl/break", RequireServiceToken(serviceToken, s.objectACLBreak))
mux.HandleFunc("POST /object-acl/unbreak", RequireServiceToken(serviceToken, s.objectACLUnbreak))
mux.HandleFunc("GET /object-acl/list", RequireServiceToken(serviceToken, s.objectACLList))
}
type authorizeRequest struct {
Role string `json:"role,omitempty"`
Permission string `json:"permission,omitempty"`
Tenant string `json:"tenant,omitempty"`
Object *objectQuery `json:"object,omitempty"`
}
// objectQuery ist der zweite, eigenstaendige Anfragemodus von /authorize
// (RBAC-11): wenn gesetzt, werden role/permission NICHT geprueft — die
// Antwort spiegelt ausschliesslich die Objektrechte-Entscheidung wider
// (Akzeptanzkriterium 3: "ohne bestehende Aufrufer ohne Objektbezug zu
// aendern" gilt dadurch strukturell, role/permission-Anfragen bleiben
// unberuehrt). Ob und wie ein Modul Objektrechte mit der rollenbasierten
// Grundberechtigung kombiniert, ist dessen eigene Entscheidung (siehe
// Ticket "Nicht Bestandteil").
type objectQuery struct {
Path []objectPathNode `json:"path"`
Right string `json:"right"`
UserID string `json:"user_id"`
GroupIDs []string `json:"group_ids,omitempty"`
}
type objectPathNode struct {
Type string `json:"type"`
ID string `json:"id"`
}
func (n objectPathNode) toDomain() policy.ObjectPathNode {
return policy.ObjectPathNode{Type: policy.ObjectType(n.Type), ID: n.ID}
}
type authorizeResponse struct {
Allowed bool `json:"allowed"`
// Reason erklaert eine Ablehnung: denied, module_inactive, tenant_required.
Reason string `json:"reason,omitempty"`
// HasEntries gilt nur im Objektrechte-Anfragemodus (DOC-23): ob entlang
// des Pfads ueberhaupt ein Eintrag existiert, unterscheidet "keine ACL
// gesetzt" (Aufrufer kann rueckwaertskompatibel behandeln) von "Zugriff
// durch einen vorhandenen Eintrag verweigert".
HasEntries *bool `json:"has_entries,omitempty"`
}
// authorize ruft AUSSCHLIESSLICH enforcer.AuthorizeRequest auf — keine
// eigene, zweite Autorisierungsentscheidung (Akzeptanzkriterium 1 aus
// RBAC-06: die HTTP-Antwort stimmt mit dem direkten Enforcer-Ergebnis
// ueberein, das ist strukturell garantiert, weil es derselbe Aufruf ist).
func (s *server) authorize(w http.ResponseWriter, r *http.Request) {
var req authorizeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest)
return
}
if req.Object != nil {
s.authorizeObject(w, r, req)
return
}
if req.Role == "" || req.Permission == "" {
http.Error(w, "role und permission sind pflichtfelder", http.StatusBadRequest)
return
}
if req.Tenant != "" {
if err := tenant.ValidateSlug(req.Tenant); err != nil {
http.Error(w, "ungültiger mandant: "+err.Error(), http.StatusBadRequest)
return
}
if s.allowed != nil && !s.allowed[req.Tenant] {
http.Error(w, "mandant für dieses service-token nicht zugelassen", http.StatusForbidden)
return
}
}
err := s.enforcer.AuthorizeRequest(r.Context(), s.flags, req.Tenant, rbac.Role(req.Role), rbac.Permission(req.Permission))
resp := authorizeResponse{Allowed: err == nil}
switch {
case err == nil:
case errors.Is(err, policy.ErrModuleInactive):
resp.Reason = "module_inactive"
case errors.Is(err, policy.ErrTenantRequired):
resp.Reason = "tenant_required"
case errors.Is(err, policy.ErrDenied):
resp.Reason = "denied"
default:
// Ein anderer Fehler als "verboten" (z.B. DB-Fehler) ist kein
// gueltiges Allow/Deny-Ergebnis, sondern ein echter Fehlerfall.
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
// authorizeObject beantwortet den Objektrechte-Anfragemodus (RBAC-11) —
// ruft AUSSCHLIESSLICH enforcer.ObjectAllowed auf, keine zweite
// Entscheidungslogik (dasselbe Prinzip wie authorize()).
func (s *server) authorizeObject(w http.ResponseWriter, r *http.Request, req authorizeRequest) {
q := req.Object
if req.Tenant == "" {
http.Error(w, "tenant ist fuer objektrechte-anfragen pflicht", http.StatusBadRequest)
return
}
if err := tenant.ValidateSlug(req.Tenant); err != nil {
http.Error(w, "ungültiger mandant: "+err.Error(), http.StatusBadRequest)
return
}
if s.allowed != nil && !s.allowed[req.Tenant] {
http.Error(w, "mandant für dieses service-token nicht zugelassen", http.StatusForbidden)
return
}
if len(q.Path) == 0 {
http.Error(w, "object.path darf nicht leer sein", http.StatusBadRequest)
return
}
if q.UserID == "" {
http.Error(w, "object.user_id ist pflicht", http.StatusBadRequest)
return
}
path := make([]policy.ObjectPathNode, len(q.Path))
for i, n := range q.Path {
path[i] = n.toDomain()
}
allowed, hasEntries, err := s.enforcer.ObjectAllowedDetailed(r.Context(), req.Tenant, path, policy.ObjectRight(q.Right), q.UserID, q.GroupIDs)
resp := authorizeResponse{Allowed: allowed, HasEntries: &hasEntries}
if err != nil {
switch {
case errors.Is(err, policy.ErrCycleInPath),
errors.Is(err, policy.ErrInvalidObjectType),
errors.Is(err, policy.ErrInvalidRight),
errors.Is(err, policy.ErrEmptyPath):
http.Error(w, err.Error(), http.StatusBadRequest)
default:
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
if !allowed {
resp.Reason = "object_denied"
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
type objectACLEntryRequest struct {
Tenant string `json:"tenant"`
ObjectType string `json:"object_type"`
ObjectID string `json:"object_id"`
PrincipalType string `json:"principal_type"`
PrincipalID string `json:"principal_id"`
Right string `json:"right"`
Effect string `json:"effect,omitempty"`
Actor string `json:"actor"`
}
func (r objectACLEntryRequest) validateCommon() error {
if r.Tenant == "" || r.ObjectID == "" || r.PrincipalID == "" || r.Actor == "" {
return errors.New("tenant, object_id, principal_id und actor sind pflichtfelder")
}
return tenant.ValidateSlug(r.Tenant)
}
func (s *server) tenantAllowed(w http.ResponseWriter, slug string) bool {
if s.allowed != nil && !s.allowed[slug] {
http.Error(w, "mandant für dieses service-token nicht zugelassen", http.StatusForbidden)
return false
}
return true
}
func (s *server) objectACLGrant(w http.ResponseWriter, r *http.Request) {
var req objectACLEntryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest)
return
}
if err := req.validateCommon(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if !s.tenantAllowed(w, req.Tenant) {
return
}
if req.Effect == "" {
req.Effect = string(policy.EffectAllow)
}
err := s.enforcer.GrantObjectRight(r.Context(), policy.ObjectACLEntry{
TenantSlug: req.Tenant,
ObjectType: policy.ObjectType(req.ObjectType),
ObjectID: req.ObjectID,
PrincipalType: policy.PrincipalType(req.PrincipalType),
PrincipalID: req.PrincipalID,
Right: policy.ObjectRight(req.Right),
Effect: policy.ACLEffect(req.Effect),
CreatedBy: req.Actor,
})
writeACLResult(w, err)
}
func (s *server) objectACLRevoke(w http.ResponseWriter, r *http.Request) {
var req objectACLEntryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest)
return
}
if err := req.validateCommon(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if !s.tenantAllowed(w, req.Tenant) {
return
}
err := s.enforcer.RevokeObjectRight(r.Context(), req.Tenant, policy.ObjectType(req.ObjectType), req.ObjectID,
policy.PrincipalType(req.PrincipalType), req.PrincipalID, policy.ObjectRight(req.Right), req.Actor)
writeACLResult(w, err)
}
type breakRequest struct {
Tenant string `json:"tenant"`
FolderID string `json:"folder_id"`
Actor string `json:"actor"`
}
func (s *server) objectACLBreak(w http.ResponseWriter, r *http.Request) {
s.handleBreak(w, r, true)
}
func (s *server) objectACLUnbreak(w http.ResponseWriter, r *http.Request) {
s.handleBreak(w, r, false)
}
func (s *server) handleBreak(w http.ResponseWriter, r *http.Request, set bool) {
var req breakRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest)
return
}
if req.Tenant == "" || req.FolderID == "" || req.Actor == "" {
http.Error(w, "tenant, folder_id und actor sind pflichtfelder", http.StatusBadRequest)
return
}
if err := tenant.ValidateSlug(req.Tenant); err != nil {
http.Error(w, "ungültiger mandant: "+err.Error(), http.StatusBadRequest)
return
}
if !s.tenantAllowed(w, req.Tenant) {
return
}
var err error
if set {
err = s.enforcer.SetInheritanceBreak(r.Context(), req.Tenant, req.FolderID, req.Actor)
} else {
err = s.enforcer.ClearInheritanceBreak(r.Context(), req.Tenant, req.FolderID, req.Actor)
}
writeACLResult(w, err)
}
func (s *server) objectACLList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
tenantSlug, objType, objectID := q.Get("tenant"), q.Get("object_type"), q.Get("object_id")
if tenantSlug == "" || objType == "" || objectID == "" {
http.Error(w, "tenant, object_type und object_id sind pflicht", http.StatusBadRequest)
return
}
if err := tenant.ValidateSlug(tenantSlug); err != nil {
http.Error(w, "ungültiger mandant: "+err.Error(), http.StatusBadRequest)
return
}
if !s.tenantAllowed(w, tenantSlug) {
return
}
entries, err := s.enforcer.ListObjectRights(r.Context(), tenantSlug, policy.ObjectType(objType), objectID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(entries)
}
func writeACLResult(w http.ResponseWriter, err error) {
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, policy.ErrInvalidObjectType),
errors.Is(err, policy.ErrInvalidPrincipalType),
errors.Is(err, policy.ErrInvalidRight),
errors.Is(err, policy.ErrInvalidEffect):
status = http.StatusBadRequest
}
http.Error(w, err.Error(), status)
return
}
w.WriteHeader(http.StatusNoContent)
}