152 lines
4.7 KiB
Go
152 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
|
||
|
|
}
|