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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
351 lines
12 KiB
Go
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)
|
|
}
|