432 lines
14 KiB
Go
432 lines
14 KiB
Go
package httpserver
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tim/cairnquire/apps/server/internal/auth"
|
|
"github.com/tim/cairnquire/apps/server/internal/docs"
|
|
)
|
|
|
|
type permissionPageData struct {
|
|
ResourceType string
|
|
ResourceID string
|
|
Title string
|
|
Users []permissionUserData
|
|
}
|
|
|
|
type permissionUserData struct {
|
|
ID string
|
|
Email string
|
|
DisplayName string
|
|
Role string
|
|
CreatedAt string
|
|
LastSeenAt string
|
|
Permission string
|
|
PermissionText string
|
|
GrantCreatedAt string
|
|
GrantedBy string
|
|
GrantSummary string
|
|
}
|
|
|
|
func (s *Server) handlePermissionsPage(w http.ResponseWriter, r *http.Request) {
|
|
principal, _ := requirePrincipal(r)
|
|
resourceType, resourceID := permissionResourceFromRequest(r)
|
|
if resourceType == "" {
|
|
resourceType = auth.ResourceDocument
|
|
}
|
|
if resourceType == auth.ResourceDocument {
|
|
resourceID = strings.TrimSpace(resourceID)
|
|
if resourceID == "" {
|
|
resourceID = strings.TrimSpace(r.URL.Query().Get("path"))
|
|
}
|
|
}
|
|
if resourceID == "" && resourceType != auth.ResourceGlobal && resourceType != auth.ResourceFolder {
|
|
s.renderError(w, r, http.StatusBadRequest, "resource is required")
|
|
return
|
|
}
|
|
if !s.canAdminResource(r, resourceType, resourceID, nil) {
|
|
s.renderError(w, r, http.StatusNotFound, "document not found")
|
|
return
|
|
}
|
|
data, err := s.buildPermissionPageData(r, principal, resourceType, resourceID)
|
|
if err != nil {
|
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
s.renderTemplate(w, r, http.StatusOK, "permissions.gohtml", layoutData{
|
|
Title: "Permissions: " + data.Title,
|
|
BodyClass: "page-permissions",
|
|
BodyTemplate: "permissions_content",
|
|
Data: data,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handlePermissionsForm(w http.ResponseWriter, r *http.Request) {
|
|
principal, _ := requirePrincipal(r)
|
|
if err := r.ParseForm(); err != nil {
|
|
s.renderError(w, r, http.StatusBadRequest, "invalid permissions form")
|
|
return
|
|
}
|
|
resourceType := auth.ResourceType(r.FormValue("resourceType"))
|
|
resourceID := r.FormValue("resourceId")
|
|
updates := permissionUpdatesFromForm(r)
|
|
if _, err := s.auth.UpdateResourcePermissions(r.Context(), principal, resourceType, resourceID, updates); err != nil {
|
|
s.renderError(w, r, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/permissions?resourceType="+queryEscape(string(resourceType))+"&resourceId="+queryEscape(resourceID), http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) handlePermissionsAPI(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
s.handlePermissionsGetAPI(w, r)
|
|
case http.MethodPatch:
|
|
s.handlePermissionsPatchAPI(w, r)
|
|
default:
|
|
writeJSONWithStatus(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
|
}
|
|
}
|
|
|
|
func (s *Server) handlePermissionsGetAPI(w http.ResponseWriter, r *http.Request) {
|
|
principal, _ := requirePrincipal(r)
|
|
resourceType, resourceID := permissionResourceFromRequest(r)
|
|
if !s.canAdminResource(r, resourceType, resourceID, nil) {
|
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "resource not found"})
|
|
return
|
|
}
|
|
permissions, err := s.auth.ListResourcePermissions(r.Context(), principal, resourceType, resourceID)
|
|
if err != nil {
|
|
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
users, err := s.auth.ListUsers(r.Context())
|
|
if err != nil {
|
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"resourceType": resourceType,
|
|
"resourceId": resourceID,
|
|
"permissions": permissions,
|
|
"users": users,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handlePermissionsPatchAPI(w http.ResponseWriter, r *http.Request) {
|
|
principal, _ := requirePrincipal(r)
|
|
var req struct {
|
|
ResourceType auth.ResourceType `json:"resourceType"`
|
|
ResourceID string `json:"resourceId"`
|
|
Users []auth.ResourcePermissionUpdate `json:"users"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
return
|
|
}
|
|
if !s.canAdminResource(r, req.ResourceType, req.ResourceID, nil) {
|
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "resource not found"})
|
|
return
|
|
}
|
|
permissions, err := s.auth.UpdateResourcePermissions(r.Context(), principal, req.ResourceType, req.ResourceID, req.Users)
|
|
if err != nil {
|
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"permissions": permissions})
|
|
}
|
|
|
|
func (s *Server) buildPermissionPageData(r *http.Request, principal auth.Principal, resourceType auth.ResourceType, resourceID string) (permissionPageData, error) {
|
|
users, err := s.auth.ListUsers(r.Context())
|
|
if err != nil {
|
|
return permissionPageData{}, err
|
|
}
|
|
grants, err := s.auth.ListResourcePermissions(r.Context(), principal, resourceType, resourceID)
|
|
if err != nil {
|
|
return permissionPageData{}, err
|
|
}
|
|
grantsByUser := make(map[string]auth.Permission, len(grants))
|
|
grantMetaByUser := make(map[string]auth.ResourcePermission, len(grants))
|
|
for _, grant := range grants {
|
|
grantsByUser[grant.UserID] = grant.Permission
|
|
grantMetaByUser[grant.UserID] = grant
|
|
}
|
|
usersByID := make(map[string]auth.User, len(users))
|
|
for _, user := range users {
|
|
usersByID[user.ID] = user
|
|
}
|
|
result := permissionPageData{
|
|
ResourceType: string(resourceType),
|
|
ResourceID: resourceID,
|
|
Title: permissionResourceTitle(resourceType, resourceID),
|
|
Users: make([]permissionUserData, 0, len(users)),
|
|
}
|
|
for _, user := range users {
|
|
if user.Disabled {
|
|
continue
|
|
}
|
|
permission := grantsByUser[user.ID]
|
|
grant := grantMetaByUser[user.ID]
|
|
result.Users = append(result.Users, permissionUserData{
|
|
ID: user.ID,
|
|
Email: user.Email,
|
|
DisplayName: user.DisplayName,
|
|
Role: string(user.Role),
|
|
CreatedAt: formatPermissionTime(user.CreatedAt),
|
|
LastSeenAt: formatPermissionTime(user.LastSeenAt),
|
|
Permission: string(permission),
|
|
PermissionText: permissionLabel(permission),
|
|
GrantCreatedAt: formatPermissionTime(grant.CreatedAt),
|
|
GrantedBy: grantActorLabel(grant.GrantedBy, usersByID),
|
|
GrantSummary: grantSummary(grant, usersByID),
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func permissionResourceTitle(resourceType auth.ResourceType, resourceID string) string {
|
|
switch resourceType {
|
|
case auth.ResourceDocument:
|
|
return "Page " + resourceID
|
|
case auth.ResourceFolder:
|
|
if resourceID == "" {
|
|
return "Root folder"
|
|
}
|
|
return "Folder " + resourceID
|
|
case auth.ResourceCollection:
|
|
return "Collection " + resourceID
|
|
default:
|
|
return string(resourceType)
|
|
}
|
|
}
|
|
|
|
func permissionResourceFromRequest(r *http.Request) (auth.ResourceType, string) {
|
|
return auth.ResourceType(r.URL.Query().Get("resourceType")), r.URL.Query().Get("resourceId")
|
|
}
|
|
|
|
func permissionUpdatesFromForm(r *http.Request) []auth.ResourcePermissionUpdate {
|
|
var updates []auth.ResourcePermissionUpdate
|
|
for key, values := range r.PostForm {
|
|
if !strings.HasPrefix(key, "permission:") || len(values) == 0 {
|
|
continue
|
|
}
|
|
userID := strings.TrimPrefix(key, "permission:")
|
|
permission := auth.Permission(values[0])
|
|
updates = append(updates, auth.ResourcePermissionUpdate{UserID: userID, Permission: permission})
|
|
}
|
|
return updates
|
|
}
|
|
|
|
func permissionLabel(permission auth.Permission) string {
|
|
switch permission {
|
|
case auth.PermissionRead:
|
|
return "View"
|
|
case auth.PermissionWrite:
|
|
return "Edit"
|
|
case auth.PermissionAdmin:
|
|
return "Admin"
|
|
default:
|
|
return "No access"
|
|
}
|
|
}
|
|
|
|
func grantSummary(grant auth.ResourcePermission, usersByID map[string]auth.User) string {
|
|
if grant.ID == "" {
|
|
return "No explicit grant"
|
|
}
|
|
parts := []string{"Explicit " + permissionLabel(grant.Permission)}
|
|
if grant.CreatedAt.IsZero() {
|
|
return strings.Join(parts, " · ")
|
|
}
|
|
parts = append(parts, "granted "+formatPermissionTime(grant.CreatedAt))
|
|
if actor := grantActorLabel(grant.GrantedBy, usersByID); actor != "" {
|
|
parts = append(parts, "by "+actor)
|
|
}
|
|
return strings.Join(parts, " · ")
|
|
}
|
|
|
|
func grantActorLabel(userID string, usersByID map[string]auth.User) string {
|
|
if userID == "" {
|
|
return ""
|
|
}
|
|
if user, ok := usersByID[userID]; ok {
|
|
if user.DisplayName != "" {
|
|
return user.DisplayName
|
|
}
|
|
return user.Email
|
|
}
|
|
return userID
|
|
}
|
|
|
|
func formatPermissionTime(value time.Time) string {
|
|
if value.IsZero() {
|
|
return "Never"
|
|
}
|
|
return value.Local().Format("Jan 2, 2006 3:04 PM")
|
|
}
|
|
|
|
func (s *Server) filterReadableDocuments(r *http.Request, records []docs.DocumentRecord) ([]docs.DocumentRecord, error) {
|
|
filtered := make([]docs.DocumentRecord, 0, len(records))
|
|
for _, record := range records {
|
|
if ok, err := s.canReadDocumentRecord(r, record); err != nil {
|
|
return nil, err
|
|
} else if ok {
|
|
filtered = append(filtered, record)
|
|
}
|
|
}
|
|
return filtered, nil
|
|
}
|
|
|
|
func (s *Server) filterReadableSearchResults(r *http.Request, results []docs.SearchResult) ([]docs.SearchResult, error) {
|
|
filtered := make([]docs.SearchResult, 0, len(results))
|
|
for _, result := range results {
|
|
record, err := s.repository.GetDocumentByPath(r.Context(), result.Path)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
continue
|
|
}
|
|
return nil, err
|
|
}
|
|
if ok, err := s.canReadDocumentRecord(r, *record); err != nil {
|
|
return nil, err
|
|
} else if ok {
|
|
filtered = append(filtered, result)
|
|
}
|
|
}
|
|
return filtered, nil
|
|
}
|
|
|
|
func (s *Server) documentRecordForRequestPath(r *http.Request, requestPath string) (*docs.DocumentRecord, error) {
|
|
clean := strings.Trim(strings.TrimSpace(requestPath), "/")
|
|
if clean == "" {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
if !strings.HasSuffix(strings.ToLower(clean), ".md") {
|
|
clean += ".md"
|
|
}
|
|
return s.repository.GetDocumentByPath(r.Context(), clean)
|
|
}
|
|
|
|
func (s *Server) canReadDocumentRecord(r *http.Request, record docs.DocumentRecord) (bool, error) {
|
|
return s.documentPermissionAllows(r, record.Path, record.Tags, auth.PermissionRead)
|
|
}
|
|
|
|
func (s *Server) canWriteDocumentRecord(r *http.Request, record docs.DocumentRecord) (bool, error) {
|
|
return s.documentPermissionAllows(r, record.Path, record.Tags, auth.PermissionWrite)
|
|
}
|
|
|
|
func (s *Server) canAdminDocumentRecord(r *http.Request, record docs.DocumentRecord) (bool, error) {
|
|
return s.documentPermissionAllows(r, record.Path, record.Tags, auth.PermissionAdmin)
|
|
}
|
|
|
|
func (s *Server) documentPermissionAllows(r *http.Request, path string, tags []string, required auth.Permission) (bool, error) {
|
|
principal, ok := principalFromContext(r.Context())
|
|
if !ok {
|
|
return false, nil
|
|
}
|
|
if principal.Role == auth.RoleAdmin {
|
|
return true, nil
|
|
}
|
|
permission, err := s.auth.EffectiveResourcePermission(r.Context(), principal, auth.ResourceDocument, path, tags)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return auth.PermissionAllows(permission, required), nil
|
|
}
|
|
|
|
func (s *Server) canAdminResource(r *http.Request, resourceType auth.ResourceType, resourceID string, tags []string) bool {
|
|
principal, ok := principalFromContext(r.Context())
|
|
if !ok {
|
|
return false
|
|
}
|
|
if principal.Role == auth.RoleAdmin {
|
|
return true
|
|
}
|
|
permission, err := s.auth.EffectiveResourcePermission(r.Context(), principal, resourceType, resourceID, tags)
|
|
return err == nil && auth.PermissionAllows(permission, auth.PermissionAdmin)
|
|
}
|
|
|
|
func (s *Server) canWriteNewDocument(r *http.Request, pagePath string) bool {
|
|
principal, ok := principalFromContext(r.Context())
|
|
if !ok {
|
|
return false
|
|
}
|
|
if principal.Role == auth.RoleAdmin {
|
|
return true
|
|
}
|
|
return s.canWriteFolder(r, documentFolder(pagePath))
|
|
}
|
|
|
|
func (s *Server) canReadNewDocument(r *http.Request, pagePath string) bool {
|
|
principal, ok := principalFromContext(r.Context())
|
|
if !ok {
|
|
return false
|
|
}
|
|
if principal.Role == auth.RoleAdmin {
|
|
return true
|
|
}
|
|
return s.canReadFolder(r, documentFolder(pagePath))
|
|
}
|
|
|
|
func (s *Server) canReadFolder(r *http.Request, folder string) bool {
|
|
principal, ok := principalFromContext(r.Context())
|
|
if !ok {
|
|
return false
|
|
}
|
|
if principal.Role == auth.RoleAdmin {
|
|
return true
|
|
}
|
|
permission, err := s.auth.EffectiveResourcePermission(r.Context(), principal, auth.ResourceFolder, folder, nil)
|
|
return err == nil && auth.PermissionAllows(permission, auth.PermissionRead)
|
|
}
|
|
|
|
func (s *Server) canWriteFolder(r *http.Request, folder string) bool {
|
|
principal, ok := principalFromContext(r.Context())
|
|
if !ok {
|
|
return false
|
|
}
|
|
if principal.Role == auth.RoleAdmin {
|
|
return true
|
|
}
|
|
permission, err := s.auth.EffectiveResourcePermission(r.Context(), principal, auth.ResourceFolder, folder, nil)
|
|
return err == nil && auth.PermissionAllows(permission, auth.PermissionWrite)
|
|
}
|
|
|
|
func (s *Server) grantDocumentCreatorAdmin(r *http.Request, path string) error {
|
|
principal, ok := principalFromContext(r.Context())
|
|
if !ok || principal.UserID == "" {
|
|
return nil
|
|
}
|
|
return s.auth.EnsureResourcePermission(r.Context(), principal, auth.ResourceDocument, path, auth.ResourcePermissionUpdate{
|
|
UserID: principal.UserID,
|
|
Permission: auth.PermissionAdmin,
|
|
})
|
|
}
|
|
|
|
func lastPathSegment(path string) string {
|
|
trimmed := strings.Trim(path, "/")
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
parts := strings.Split(trimmed, "/")
|
|
return parts[len(parts)-1]
|
|
}
|
|
|
|
func documentFolder(path string) string {
|
|
trimmed := strings.Trim(path, "/")
|
|
segment := lastPathSegment(trimmed)
|
|
if segment == "" || segment == trimmed {
|
|
return ""
|
|
}
|
|
return strings.TrimSuffix(trimmed, "/"+segment)
|
|
}
|
|
|
|
func queryEscape(value string) string {
|
|
return strings.NewReplacer("%", "%25", " ", "%20", "?", "%3F", "&", "%26", "=", "%3D", "#", "%23").Replace(value)
|
|
}
|