FDN-01: repository & projektgerüst

Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
This commit is contained in:
2026-08-11 21:27:53 +02:00
parent 40ed80da71
commit 9a24ea29e1
274 changed files with 53708 additions and 0 deletions
+458
View File
@@ -0,0 +1,458 @@
// Permission-model HTTP handlers (see internal/storage/permissions.go):
//
// POST/GET/DELETE /api/permission-groups
// GET/POST/DELETE /api/permission-groups/{id}/members
// GET/POST/DELETE /api/document-types/{id}/grants
// GET/POST/DELETE /api/tags/{id}/grants
// GET/POST/DELETE /api/documents/{id}/grants (access may be 'deny')
//
// Group and grant administration require domain_admin (s.authAdmin). Ownership
// is enforced in the store layer (id + tenant_id). Every mutation is
// audit-logged (EventPermissionGrantChanged), including failures.
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
// logGrant records a permission mutation, success or failure.
func (s *Server) logGrant(r *http.Request, tenantID *int64, username, detail string, ok bool) {
s.audlog.Log(audit.Entry{
EventType: audit.EventPermissionGrantChanged, Username: username, TenantID: tenantID,
IPAddress: s.remoteIP(r), Success: ok, Detail: detail,
})
}
// grantStatus maps store errors to an HTTP status.
func grantStatus(err error) int {
switch {
case errors.Is(err, storage.ErrPermissionGroupNotFound):
return http.StatusNotFound
case errors.Is(err, storage.ErrGrantNotFound):
return http.StatusNotFound
case errors.Is(err, storage.ErrDuplicatePermissionGroup):
return http.StatusConflict
default:
return http.StatusInternalServerError
}
}
// --- permission groups ---
type permissionGroupRequest struct {
Name string `json:"name"`
}
// handleListPermissionGroups handles GET /api/permission-groups.
func (s *Server) handleListPermissionGroups(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
groups, err := s.store.ListPermissionGroups(r.Context(), *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list permission groups failed")
return
}
writeJSON(w, http.StatusOK, groups)
}
// handleCreatePermissionGroup handles POST /api/permission-groups (domain_admin+).
func (s *Server) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
var req permissionGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
g, err := s.store.CreatePermissionGroup(r.Context(), *sess.TenantID, req.Name)
if err != nil {
s.logGrant(r, sess.TenantID, sess.Username, "group_create name:"+req.Name+" err:"+err.Error(), false)
writeError(w, grantStatus(err), "create permission group failed")
return
}
s.logGrant(r, sess.TenantID, sess.Username, "group_create id:"+strconv.FormatInt(g.ID, 10)+" name:"+g.Name, true)
writeJSON(w, http.StatusCreated, g)
}
// handleDeletePermissionGroup handles DELETE /api/permission-groups/{id} (domain_admin+).
func (s *Server) handleDeletePermissionGroup(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.store.DeletePermissionGroup(r.Context(), id, *sess.TenantID); err != nil {
s.logGrant(r, sess.TenantID, sess.Username, "group_delete id:"+strconv.FormatInt(id, 10)+" err:"+err.Error(), false)
writeError(w, grantStatus(err), "delete permission group failed")
return
}
s.logGrant(r, sess.TenantID, sess.Username, "group_delete id:"+strconv.FormatInt(id, 10), true)
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// --- group membership ---
type groupMemberRequest struct {
UserID int64 `json:"user_id"`
}
// handleAddGroupMember handles POST /api/permission-groups/{id}/members (domain_admin+).
func (s *Server) handleAddGroupMember(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
groupID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid group id")
return
}
var req groupMemberRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.UserID == 0 {
writeError(w, http.StatusBadRequest, "user_id is required")
return
}
if err := s.store.AddGroupMember(r.Context(), groupID, req.UserID, *sess.TenantID); err != nil {
s.logGrant(r, sess.TenantID, sess.Username, "member_add group:"+strconv.FormatInt(groupID, 10)+" user:"+strconv.FormatInt(req.UserID, 10)+" err:"+err.Error(), false)
writeError(w, grantStatus(err), "add group member failed")
return
}
s.logGrant(r, sess.TenantID, sess.Username, "member_add group:"+strconv.FormatInt(groupID, 10)+" user:"+strconv.FormatInt(req.UserID, 10), true)
writeJSON(w, http.StatusOK, map[string]string{"status": "added"})
}
// handleRemoveGroupMember handles DELETE /api/permission-groups/{id}/members/{userId} (domain_admin+).
func (s *Server) handleRemoveGroupMember(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
groupID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid group id")
return
}
userID, err := strconv.ParseInt(r.PathValue("userId"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid user id")
return
}
if err := s.store.RemoveGroupMember(r.Context(), groupID, userID, *sess.TenantID); err != nil {
s.logGrant(r, sess.TenantID, sess.Username, "member_remove group:"+strconv.FormatInt(groupID, 10)+" user:"+strconv.FormatInt(userID, 10)+" err:"+err.Error(), false)
writeError(w, grantStatus(err), "remove group member failed")
return
}
s.logGrant(r, sess.TenantID, sess.Username, "member_remove group:"+strconv.FormatInt(groupID, 10)+" user:"+strconv.FormatInt(userID, 10), true)
writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
}
// handleListGroupMembers handles GET /api/permission-groups/{id}/members (domain_admin+).
func (s *Server) handleListGroupMembers(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
groupID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid group id")
return
}
members, err := s.store.ListGroupMembersDetailed(r.Context(), groupID, *sess.TenantID)
if err != nil {
writeError(w, grantStatus(err), "list group members failed")
return
}
writeJSON(w, http.StatusOK, members)
}
// --- grants (document-type / tag / document) ---
type grantRequest struct {
GroupID int64 `json:"group_id"`
Access string `json:"access"`
}
// handleSetDocumentTypeGrant handles POST /api/document-types/{id}/grants (domain_admin+).
func (s *Server) handleSetDocumentTypeGrant(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docTypeID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document type id")
return
}
var req grantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Access == "" {
req.Access = "read"
}
if err := s.store.SetDocumentTypeGrant(r.Context(), *sess.TenantID, docTypeID, req.GroupID, req.Access); err != nil {
s.logGrant(r, sess.TenantID, sess.Username, "type_grant_set type:"+strconv.FormatInt(docTypeID, 10)+" group:"+strconv.FormatInt(req.GroupID, 10)+" err:"+err.Error(), false)
writeError(w, grantStatusValidated(err), "set document type grant failed")
return
}
s.logGrant(r, sess.TenantID, sess.Username, "type_grant_set type:"+strconv.FormatInt(docTypeID, 10)+" group:"+strconv.FormatInt(req.GroupID, 10)+" access:"+req.Access, true)
writeJSON(w, http.StatusOK, map[string]string{"status": "granted"})
}
// handleListDocumentTypeGrants handles GET /api/document-types/{id}/grants (domain_admin+).
func (s *Server) handleListDocumentTypeGrants(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docTypeID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document type id")
return
}
grants, err := s.store.ListDocumentTypeGrants(r.Context(), *sess.TenantID, docTypeID)
if err != nil {
writeError(w, grantStatus(err), "list document type grants failed")
return
}
writeJSON(w, http.StatusOK, grants)
}
// handleDeleteDocumentTypeGrant handles DELETE /api/document-types/{id}/grants (domain_admin+).
func (s *Server) handleDeleteDocumentTypeGrant(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docTypeID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document type id")
return
}
groupID, err := grantGroupID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "group_id is required")
return
}
if err := s.store.DeleteDocumentTypeGrant(r.Context(), *sess.TenantID, docTypeID, groupID); err != nil {
s.logGrant(r, sess.TenantID, sess.Username, "type_grant_delete type:"+strconv.FormatInt(docTypeID, 10)+" group:"+strconv.FormatInt(groupID, 10)+" err:"+err.Error(), false)
writeError(w, grantStatus(err), "delete document type grant failed")
return
}
s.logGrant(r, sess.TenantID, sess.Username, "type_grant_delete type:"+strconv.FormatInt(docTypeID, 10)+" group:"+strconv.FormatInt(groupID, 10), true)
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
// handleSetTagGrant handles POST /api/tags/{id}/grants (domain_admin+).
func (s *Server) handleSetTagGrant(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
tagID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tag id")
return
}
var req grantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Access == "" {
req.Access = "read"
}
if err := s.store.SetTagGrant(r.Context(), *sess.TenantID, tagID, req.GroupID, req.Access); err != nil {
s.logGrant(r, sess.TenantID, sess.Username, "tag_grant_set tag:"+strconv.FormatInt(tagID, 10)+" group:"+strconv.FormatInt(req.GroupID, 10)+" err:"+err.Error(), false)
writeError(w, grantStatusValidated(err), "set tag grant failed")
return
}
s.logGrant(r, sess.TenantID, sess.Username, "tag_grant_set tag:"+strconv.FormatInt(tagID, 10)+" group:"+strconv.FormatInt(req.GroupID, 10)+" access:"+req.Access, true)
writeJSON(w, http.StatusOK, map[string]string{"status": "granted"})
}
// handleListTagGrants handles GET /api/tags/{id}/grants (domain_admin+).
func (s *Server) handleListTagGrants(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
tagID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tag id")
return
}
grants, err := s.store.ListTagGrants(r.Context(), *sess.TenantID, tagID)
if err != nil {
writeError(w, grantStatus(err), "list tag grants failed")
return
}
writeJSON(w, http.StatusOK, grants)
}
// handleDeleteTagGrant handles DELETE /api/tags/{id}/grants (domain_admin+).
func (s *Server) handleDeleteTagGrant(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
tagID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tag id")
return
}
groupID, err := grantGroupID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "group_id is required")
return
}
if err := s.store.DeleteTagGrant(r.Context(), *sess.TenantID, tagID, groupID); err != nil {
s.logGrant(r, sess.TenantID, sess.Username, "tag_grant_delete tag:"+strconv.FormatInt(tagID, 10)+" group:"+strconv.FormatInt(groupID, 10)+" err:"+err.Error(), false)
writeError(w, grantStatus(err), "delete tag grant failed")
return
}
s.logGrant(r, sess.TenantID, sess.Username, "tag_grant_delete tag:"+strconv.FormatInt(tagID, 10)+" group:"+strconv.FormatInt(groupID, 10), true)
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
// handleSetDocumentGrant handles POST /api/documents/{id}/grants (domain_admin+).
// access may be 'read', 'write' or 'deny'.
func (s *Server) handleSetDocumentGrant(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
var req grantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Access == "" {
writeError(w, http.StatusBadRequest, "access is required (read|write|deny)")
return
}
if err := s.store.SetDocumentGrant(r.Context(), *sess.TenantID, docID, req.GroupID, sess.UserID, req.Access); err != nil {
s.logGrant(r, sess.TenantID, sess.Username, "doc_grant_set doc:"+strconv.FormatInt(docID, 10)+" group:"+strconv.FormatInt(req.GroupID, 10)+" err:"+err.Error(), false)
writeError(w, grantStatusValidated(err), "set document grant failed")
return
}
s.logGrant(r, sess.TenantID, sess.Username, "doc_grant_set doc:"+strconv.FormatInt(docID, 10)+" group:"+strconv.FormatInt(req.GroupID, 10)+" access:"+req.Access, true)
writeJSON(w, http.StatusOK, map[string]string{"status": "granted"})
}
// handleListDocumentGrants handles GET /api/documents/{id}/grants (domain_admin+).
// access may be 'read', 'write' or 'deny'.
func (s *Server) handleListDocumentGrants(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
grants, err := s.store.ListDocumentGrants(r.Context(), *sess.TenantID, docID)
if err != nil {
writeError(w, grantStatus(err), "list document grants failed")
return
}
writeJSON(w, http.StatusOK, grants)
}
// handleDeleteDocumentGrant handles DELETE /api/documents/{id}/grants (domain_admin+).
func (s *Server) handleDeleteDocumentGrant(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
groupID, err := grantGroupID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "group_id is required")
return
}
if err := s.store.DeleteDocumentGrant(r.Context(), *sess.TenantID, docID, groupID); err != nil {
s.logGrant(r, sess.TenantID, sess.Username, "doc_grant_delete doc:"+strconv.FormatInt(docID, 10)+" group:"+strconv.FormatInt(groupID, 10)+" err:"+err.Error(), false)
writeError(w, grantStatus(err), "delete document grant failed")
return
}
s.logGrant(r, sess.TenantID, sess.Username, "doc_grant_delete doc:"+strconv.FormatInt(docID, 10)+" group:"+strconv.FormatInt(groupID, 10), true)
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
// grantGroupID resolves the target group for a grant DELETE, from either the
// ?group_id query param or a JSON body {"group_id": N}.
func grantGroupID(r *http.Request) (int64, error) {
if q := r.URL.Query().Get("group_id"); q != "" {
return strconv.ParseInt(q, 10, 64)
}
var req grantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return 0, err
}
if req.GroupID == 0 {
return 0, errors.New("group_id is required")
}
return req.GroupID, nil
}
// grantStatusValidated maps an "invalid access" validation error to 400,
// otherwise defers to grantStatus.
func grantStatusValidated(err error) int {
if err == nil {
return http.StatusInternalServerError
}
if strings.Contains(err.Error(), "invalid access") {
return http.StatusBadRequest
}
return grantStatus(err)
}