go-loose/internal/server/roles.go
Daniel Noulet 3fdc8cb489 roles
2026-10-04 16:57:30 +02:00

151 lines
4.7 KiB
Go

package server
import (
"errors"
"net/http"
"strings"
"github.com/danyel/go-loose/internal/store"
)
const (
maxRoleName = 100
maxRoleDescription = 500
)
// rolesPage serves the role management screen.
func (s *Server) rolesPage(w http.ResponseWriter, _ *http.Request) {
s.serveEmbedded(w, "roles.html")
}
// listRoles serves the role management screen's data: every built-in role plus
// the tenant's own, each annotated with whether the caller may assign it.
func (s *Server) listRoles(w http.ResponseWriter, r *http.Request) {
tenantID, ok := s.requestTenant(w, r)
if !ok {
return
}
roles, err := s.store.ListRoles(r.Context(), claimsFrom(r).UserID, tenantID)
if err != nil {
s.handleStoreError(w, "list roles", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"roles": roles})
}
// createRole adds a role to the tenant. The store refuses a role that grants
// more than the caller holds, so the endpoint does not have to restate that rule.
func (s *Server) createRole(w http.ResponseWriter, r *http.Request) {
var request struct {
TenantID string `json:"tenant_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
}
if !decodeJSON(w, r, &request) {
return
}
slug := strings.TrimSpace(request.Slug)
name := strings.TrimSpace(request.Name)
description := strings.TrimSpace(request.Description)
if !validSlug(slug) {
writeError(w, http.StatusBadRequest, "slug must be lowercase letters, digits, and dashes")
return
}
if name == "" || len(name) > maxRoleName {
writeError(w, http.StatusBadRequest, "name must be between 1 and 100 characters")
return
}
if len(description) > maxRoleDescription {
writeError(w, http.StatusBadRequest, "description must be 500 characters or fewer")
return
}
created, err := s.store.CreateRole(
r.Context(), claimsFrom(r).UserID, request.TenantID, slug, name, description, request.Permissions)
if err != nil {
if errors.Is(err, store.ErrRoleSlugTaken) {
writeError(w, http.StatusConflict, "a role with this slug already exists in this tenant")
return
}
if errors.Is(err, store.ErrUnknownPermission) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.handleStoreError(w, "create role", err)
return
}
writeJSON(w, http.StatusCreated, created)
}
// updateRole renames a role and replaces the permissions it grants.
func (s *Server) updateRole(w http.ResponseWriter, r *http.Request) {
var request struct {
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
}
if !decodeJSON(w, r, &request) {
return
}
name := strings.TrimSpace(request.Name)
description := strings.TrimSpace(request.Description)
if name == "" || len(name) > maxRoleName {
writeError(w, http.StatusBadRequest, "name must be between 1 and 100 characters")
return
}
if len(description) > maxRoleDescription {
writeError(w, http.StatusBadRequest, "description must be 500 characters or fewer")
return
}
updated, err := s.store.UpdateRole(
r.Context(), claimsFrom(r).UserID, r.PathValue("id"), name, description, request.Permissions)
if err != nil {
if errors.Is(err, store.ErrUnknownPermission) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.handleStoreError(w, "update role", err)
return
}
writeJSON(w, http.StatusOK, updated)
}
// deleteRole removes one of the tenant's own roles.
func (s *Server) deleteRole(w http.ResponseWriter, r *http.Request) {
if err := s.store.DeleteRole(r.Context(), claimsFrom(r).UserID, r.PathValue("id")); err != nil {
if errors.Is(err, store.ErrRoleInUse) {
writeError(w, http.StatusConflict, "reassign the members of this role before deleting it")
return
}
s.handleStoreError(w, "delete role", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// requestTenant reads the tenant a request targets, preferring an explicit
// tenant_id and falling back to the tenant named by the host so that the role
// screen works on a tenant subdomain.
func (s *Server) requestTenant(w http.ResponseWriter, r *http.Request) (string, bool) {
if tenantID := strings.TrimSpace(r.URL.Query().Get("tenant_id")); tenantID != "" {
return tenantID, true
}
slug := s.requestTenantSlug(r.Host)
if slug == "" {
writeError(w, http.StatusBadRequest, "tenant_id is required")
return "", false
}
tenants, err := s.store.ListTenants(r.Context(), claimsFrom(r).UserID)
if err != nil {
s.handleStoreError(w, "resolve tenant", err)
return "", false
}
for _, tenant := range tenants {
if tenant.Slug == slug {
return tenant.ID, true
}
}
writeError(w, http.StatusNotFound, "resource not found or permission denied")
return "", false
}