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