go-loose/internal/server/permissions.go
Daniel Noulet c7c81fb65c feat: account menu, database roles, and single logout
Bundles the console work from the current branch: the header account
control, database-backed roles and permissions, and signing out across
the applications that share this sign-in.

The account control replaces a separate identity chip and a separate
sign-out button, which meant the header carried two controls describing
one fact. web/account.js is mounted into every page by a placeholder and
owns the keyboard handling, the outside-click behaviour and the sign-out
form once. It also fills the trigger's name, which the previous markup
left empty.

Signing out revokes the hosted-login bearer sessions this installation
issued, then redirects to the provider's end_session_endpoint when the
discovery document advertises one. Applications that authenticate
against this installation therefore lose their session too; one pointed
straight at an identity provider is out of its reach and is unchanged.

Roles and permissions become rows rather than a closed enum, so a tenant
can extend the catalog without a migration, and a role can no longer be
granted a capability that is not in it. requireSameOrigin now compares
against the origin the request arrived on instead of the configured base
URL, which defaulted to http://localhost:8080 and so rejected every
write in a deployment that never set it.

Two bugs found while verifying in a browser:

  - A profile picture never rendered. avatarURL built an absolute URL
    from the configured base URL, and the page's own img-src 'self'
    blocked it. The console's own pages now take a root-relative path;
    only /connect/userinfo, read from another origin, needs an absolute
    one, and it follows the request.
  - absoluteURL preferred the configuration over the request while its
    comment claimed the opposite, which also misdirected the logout
    redirect.

Card layout: the footer was one two-column row shared between a label
and a forty-character identifier, so the identifier took the width and
crushed its label into three stacked words. Each pair is a line now, the
value truncates with the full text in the title, and cards align their
footers across a row.
2026-10-05 19:18:07 +02:00

91 lines
3 KiB
Go

package server
import (
"errors"
"net/http"
"strings"
"github.com/danyel/go-loose/internal/store"
)
const maxPermissionLabel = 100
// listPermissions serves the whole catalog. Every management session may read it,
// because the role editor needs it to render its checkboxes.
func (s *Server) listPermissions(w http.ResponseWriter, r *http.Request) {
catalog, err := s.store.ListPermissions(r.Context())
if err != nil {
s.internalError(w, "list permissions", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"permissions": catalog})
}
// createPermission adds a name to the shared catalog so that roles can grant it.
//
// The catalog is installation-wide, so this is restricted to a system
// administrator or a holder of tenants.manage. Note what the new name does and
// does not do: it becomes grantable and is reported through /connect/userinfo,
// but nothing in the console checks for it, so it cannot unlock console
// behaviour. The response says so, and the UI repeats it.
func (s *Server) createPermission(w http.ResponseWriter, r *http.Request) {
var request struct {
Name string `json:"name"`
Label string `json:"label"`
Description string `json:"description"`
}
if !decodeJSON(w, r, &request) {
return
}
name := strings.TrimSpace(request.Name)
if !store.ValidPermissionName(name) {
writeError(w, http.StatusBadRequest,
"name must be lowercase words separated by dots, for example reports.export")
return
}
label := strings.TrimSpace(request.Label)
if label == "" || len(label) > maxPermissionLabel {
writeError(w, http.StatusBadRequest, "label must be between 1 and 100 characters")
return
}
description := strings.TrimSpace(request.Description)
if len(description) > 500 {
writeError(w, http.StatusBadRequest, "description must be 500 characters or fewer")
return
}
created, err := s.store.CreatePermission(
r.Context(), claimsFrom(r).UserID, name, label, description)
if err != nil {
switch {
case errors.Is(err, store.ErrPermissionTaken):
writeError(w, http.StatusConflict, "this permission already exists")
case errors.Is(err, store.ErrUnknownPermission):
writeError(w, http.StatusBadRequest, err.Error())
default:
s.handleStoreError(w, "create permission", err)
}
return
}
writeJSON(w, http.StatusCreated, created)
}
// deletePermission removes a catalog entry that no role grants.
func (s *Server) deletePermission(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if !store.ValidPermissionName(name) {
writeError(w, http.StatusBadRequest, "unknown permission name")
return
}
if err := s.store.DeletePermission(r.Context(), claimsFrom(r).UserID, name); err != nil {
switch {
case errors.Is(err, store.ErrPermissionInUse):
writeError(w, http.StatusConflict, "remove this permission from every role before deleting it")
case errors.Is(err, store.ErrPermissionSystem):
writeError(w, http.StatusConflict, "built-in permissions cannot be deleted")
default:
s.handleStoreError(w, "delete permission", err)
}
return
}
w.WriteHeader(http.StatusNoContent)
}