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) }