This commit is contained in:
Daniel Noulet 2026-10-04 16:57:30 +02:00
parent 6319da650c
commit 3fdc8cb489
26 changed files with 2245 additions and 488 deletions

View file

@ -8,7 +8,6 @@ import (
"time"
"github.com/danyel/go-loose/internal/key"
"github.com/danyel/go-loose/internal/role"
"github.com/danyel/go-loose/internal/store"
)
@ -33,7 +32,7 @@ func (s *Server) clientUser(user store.ClientUser) clientUserResponse {
ID: user.ID, Email: user.Email, DisplayName: user.DisplayName,
AvatarURL: s.avatarURL(user.AvatarKey), TenantID: user.TenantID,
TenantSlug: user.TenantSlug, ApplicationID: user.ApplicationID, Application: user.Application,
Role: user.Role, Permissions: role.Role(user.Role).Permissions(),
Role: user.Role, Permissions: user.Permissions,
}
}

View file

@ -39,3 +39,100 @@ func TestRequireSameOriginRejectsDifferentTenantHost(t *testing.T) {
t.Fatalf("status = %d, want %d", response.Code, http.StatusForbidden)
}
}
// TestRequireSameOriginSurvivesStaleBaseURL covers the deployment that made
// profile saves fail with "same-origin request required": the container was
// started with a base URL of "http://localhost:" while users browsed to
// https://auth.urpi.be. The configured base URL must not be the only accepted
// origin, or a stale value breaks every state-changing route at once.
func TestRequireSameOriginSurvivesStaleBaseURL(t *testing.T) {
for _, baseURL := range []string{"http://localhost:", "", "http://localhost:8080", "not a url"} {
server := &Server{cfg: config.Config{BaseURL: baseURL}}
handler := server.requireSameOrigin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
request := httptest.NewRequest(http.MethodPut, "https://auth.urpi.be/api/v1/profile", nil)
request.Header.Set("Origin", "https://auth.urpi.be")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Errorf("base URL %q: status = %d, want %d", baseURL, response.Code, http.StatusNoContent)
}
}
}
// TestRequireSameOriginAcceptsForwardedHTTPS covers a reverse proxy that
// terminates TLS: the server sees a plain HTTP connection, so the scheme has to
// come from X-Forwarded-Proto rather than from r.TLS.
func TestRequireSameOriginAcceptsForwardedHTTPS(t *testing.T) {
server := &Server{cfg: config.Config{BaseURL: "http://go-loose:8080"}}
handler := server.requireSameOrigin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
request := httptest.NewRequest(http.MethodPut, "http://auth.urpi.be/api/v1/profile", nil)
request.Host = "auth.urpi.be"
request.Header.Set("Origin", "https://auth.urpi.be")
request.Header.Set("X-Forwarded-Proto", "https")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", response.Code, http.StatusNoContent)
}
}
func TestRequireSameOriginFallsBackToReferer(t *testing.T) {
server := &Server{cfg: config.Config{BaseURL: "https://auth.dev"}}
handler := server.requireSameOrigin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
request := httptest.NewRequest(http.MethodPost, "https://auth.dev/api/v1/tenants", nil)
request.Header.Set("Referer", "https://auth.dev/console")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", response.Code, http.StatusNoContent)
}
}
func TestRequireSameOriginRejectsMissingAndForeignSources(t *testing.T) {
cases := []struct {
name string
origin string
referer string
}{
{name: "no origin or referer"},
{name: "foreign origin", origin: "https://evil.example"},
{name: "null origin", origin: "null"},
{name: "same host different scheme", origin: "http://auth.dev"},
{name: "origin is a prefix of the host", origin: "https://auth.dev.evil.example"},
{name: "referer from another tenant", referer: "https://ypto.auth.dev/profile"},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
server := &Server{cfg: config.Config{BaseURL: "https://auth.dev"}}
handler := server.requireSameOrigin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("handler reached for a request that was not same-origin")
}))
request := httptest.NewRequest(http.MethodPost, "https://auth.dev/api/v1/tenants", nil)
if testCase.origin != "" {
request.Header.Set("Origin", testCase.origin)
}
if testCase.referer != "" {
request.Header.Set("Referer", testCase.referer)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", response.Code, http.StatusForbidden)
}
})
}
}

View file

@ -52,12 +52,17 @@ func TestAvatarURLIsAbsoluteAndOmittedWithoutKey(t *testing.T) {
}
}
// TestClientUserExposesRoleCapabilitiesAndPicture checks that the identity
// handed to a client application carries the capabilities the store resolved for
// the membership's role rather than anything the server infers.
func TestClientUserExposesRoleCapabilitiesAndPicture(t *testing.T) {
server := profileTestServer()
key := "avatar-key"
payload := server.clientUser(store.ClientUser{
ID: "user-1", Email: "operator@example.test", DisplayName: "Operator",
TenantSlug: "nmbs", Application: "guess", Role: string(role.Operator), AvatarKey: &key,
TenantSlug: "nmbs", Application: "guess", Role: "operator",
Permissions: []string{string(role.ManageKeys), string(role.EditProfile)},
AvatarKey: &key,
})
if payload.Role != "operator" {
t.Fatalf("role = %q", payload.Role)
@ -65,17 +70,21 @@ func TestClientUserExposesRoleCapabilitiesAndPicture(t *testing.T) {
if !slices.Contains(payload.Permissions, string(role.ManageKeys)) {
t.Fatalf("permissions = %v, want keys.manage", payload.Permissions)
}
if slices.Contains(payload.Permissions, string(role.ManageUsers)) {
t.Fatalf("permissions = %v, must not grant users.manage to an operator", payload.Permissions)
if slices.Contains(payload.Permissions, string(role.ManageTenants)) {
t.Fatalf("permissions = %v, must not grant tenants.manage", payload.Permissions)
}
if !strings.HasSuffix(payload.AvatarURL, "/api/v1/avatars/avatar-key") {
t.Fatalf("avatar url = %q", payload.AvatarURL)
}
}
// TestClientUserWithoutMembershipIsLeastPrivileged documents the pass-through: a
// user with no membership is reported with the permissions of the built-in User
// role, which the store resolves, so an empty capability set reaches clients
// rather than an error.
func TestClientUserWithoutMembershipIsLeastPrivileged(t *testing.T) {
server := profileTestServer()
payload := server.clientUser(store.ClientUser{Role: string(role.User)})
payload := server.clientUser(store.ClientUser{Role: "user", Permissions: []string{string(role.EditProfile)}})
if payload.AvatarURL != "" {
t.Fatalf("avatar url = %q, want empty", payload.AvatarURL)
}
@ -138,38 +147,6 @@ func TestUpdateProfileRejectsOversizedBody(t *testing.T) {
}
}
func TestInvitableRoleExcludesOwner(t *testing.T) {
if _, ok := invitableRole(string(role.Owner)); ok {
t.Fatal("an invitation must not be able to grant ownership")
}
for _, item := range role.Grantable() {
if _, ok := invitableRole(string(item)); !ok {
t.Fatalf("invitableRole(%q) = false", item)
}
}
for _, item := range []string{"", "superuser", "Owner", "user'", "owner"} {
if item == string(role.Owner) {
continue
}
if _, ok := invitableRole(item); ok {
t.Fatalf("invitableRole(%q) = true", item)
}
}
}
func TestAssignableRoleAcceptsEveryKnownRole(t *testing.T) {
for _, item := range role.All() {
if got, ok := assignableRole(string(item)); !ok || got != string(item) {
t.Fatalf("assignableRole(%q) = %q, %v", item, got, ok)
}
}
for _, item := range []string{"", "superuser", "Owner", "admin,owner"} {
if _, ok := assignableRole(item); ok {
t.Fatalf("assignableRole(%q) = true", item)
}
}
}
func TestProfilePageIsEmbedded(t *testing.T) {
content, err := web.Files.ReadFile("profile.html")
if err != nil {

151
internal/server/roles.go Normal file
View file

@ -0,0 +1,151 @@
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
}

View file

@ -0,0 +1,177 @@
package server
import (
"bytes"
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path"
"strings"
"testing"
"github.com/danyel/go-loose/internal/config"
"github.com/danyel/go-loose/internal/session"
"github.com/danyel/go-loose/web"
)
// withClaims injects a management session the way requireSession would, so a
// handler can be called directly without standing up a session manager.
func withClaims(request *http.Request, claims session.Claims) context.Context {
return context.WithValue(request.Context(), claimsKey, claims)
}
func TestRolesPageIsEmbedded(t *testing.T) {
content, err := web.Files.ReadFile("roles.html")
if err != nil {
t.Fatalf("roles.html must be embedded: %v", err)
}
for _, marker := range []string{
"role-list", "role-form", "permission-list", "tenant-context", "/assets/roles.js",
} {
if !bytes.Contains(content, []byte(marker)) {
t.Errorf("roles.html is missing %q", marker)
}
}
}
// TestRolesScriptIsServed guards the asset allowlist: roles.js is a new file, and
// the handler serves assets by exact name rather than by pattern.
func TestRolesScriptIsServed(t *testing.T) {
server := profileTestServer()
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/assets/roles.js", nil)
request.SetPathValue("name", "roles.js")
response := httptest.NewRecorder()
server.asset(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", response.Code, http.StatusOK)
}
if !strings.Contains(response.Body.String(), "/api/v1/roles") {
t.Error("roles.js does not call the role endpoints")
}
}
func TestAssetAllowlistRejectsUnknownFiles(t *testing.T) {
server := profileTestServer()
for _, name := range []string{"admin.js", "roles.html", "app.js.map", "secret.env"} {
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/assets/"+name, nil)
request.SetPathValue("name", name)
response := httptest.NewRecorder()
server.asset(response, request)
if response.Code != http.StatusNotFound {
t.Errorf("asset(%q) status = %d, want %d", name, response.Code, http.StatusNotFound)
}
}
}
// TestAssetTraversalCollapsesToTheAllowlistedName documents that a traversal
// attempt cannot escape the allowlist: path.Base reduces it to the bare file
// name, which is then matched against the list like any other request.
func TestAssetTraversalCollapsesToTheAllowlistedName(t *testing.T) {
server := profileTestServer()
for _, name := range []string{"../roles.js", "../../etc/passwd", "..%2froles.js"} {
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/assets/"+name, nil)
request.SetPathValue("name", name)
response := httptest.NewRecorder()
server.asset(response, request)
served := path.Base(name)
if served == "roles.js" {
if response.Code != http.StatusOK {
t.Errorf("asset(%q) status = %d, want the allowlisted file to serve", name, response.Code)
}
continue
}
if response.Code != http.StatusNotFound {
t.Errorf("asset(%q) status = %d, want %d", name, response.Code, http.StatusNotFound)
}
}
}
func TestCreateRoleRejectsMalformedInputBeforeTouchingTheStore(t *testing.T) {
cases := []struct {
name string
body string
}{
{"missing slug", `{"name":"Role","permissions":[]}`},
{"uppercase slug", `{"slug":"Release Manager","name":"Role","permissions":[]}`},
{"slug starting with a dash", `{"slug":"-role","name":"Role","permissions":[]}`},
{"empty name", `{"slug":"role","name":" ","permissions":[]}`},
{"unknown permission", `{"slug":"role","name":"Role","permissions":["superpower"]}`},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
server := profileTestServer()
request := httptest.NewRequest(http.MethodPost, "https://auth.example.test/api/v1/roles",
strings.NewReader(testCase.body))
request.SetPathValue("id", "")
request = request.WithContext(withClaims(request, session.Claims{UserID: "user-1", Management: true}))
response := httptest.NewRecorder()
server.createRole(response, request)
// The store would need a database, so a 400 proves validation ran first.
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d (body %s)", response.Code, http.StatusBadRequest, response.Body)
}
})
}
}
func TestListRolesRequiresATenant(t *testing.T) {
server := profileTestServer()
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/api/v1/roles", nil)
request = request.WithContext(withClaims(request, session.Claims{UserID: "user-1", Management: true}))
response := httptest.NewRecorder()
server.listRoles(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest)
}
if !strings.Contains(response.Body.String(), "tenant_id") {
t.Errorf("body = %s, want it to name the missing parameter", response.Body)
}
}
// TestRoleRoutesAreRegistered documents the two wrappers the role routes rely
// on. The server under test is not installed, so requireSession redirects every
// session-guarded route to the installer; an unregistered route would answer 404
// instead, which is what makes this a registration check.
func TestRoleRoutesAreRegistered(t *testing.T) {
// Handler() wraps every route in the request logger, so this server needs a
// logger where the profile tests, which call handlers directly, do not.
server := &Server{
cfg: config.Config{BaseURL: "https://auth.example.test"},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
handler := server.Handler()
cases := []struct {
method, path string
}{
{http.MethodGet, "/api/v1/roles?tenant_id=t"},
{http.MethodPost, "/api/v1/roles"},
{http.MethodPut, "/api/v1/roles/abc"},
{http.MethodDelete, "/api/v1/roles/abc"},
{http.MethodGet, "/roles"},
}
for _, testCase := range cases {
t.Run(testCase.method+" "+testCase.path, func(t *testing.T) {
request := httptest.NewRequest(testCase.method, "https://auth.example.test"+testCase.path, nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want %d from requireSession", response.Code, http.StatusSeeOther)
}
if location := response.Header().Get("Location"); location != "/install" {
t.Fatalf("location = %q, want /install", location)
}
})
}
}

View file

@ -120,6 +120,7 @@ func (s *Server) Handler() http.Handler {
// browser favicon request would redirect to /install and rotate its CSRF cookie.
mux.Handle("GET /{$}", s.requireSession(http.HandlerFunc(s.index)))
mux.Handle("GET /profile", s.requireSession(http.HandlerFunc(s.profilePage)))
mux.Handle("GET /roles", s.requireSession(http.HandlerFunc(s.rolesPage)))
mux.Handle("GET /api/v1/profile", s.requireSession(http.HandlerFunc(s.profile)))
mux.Handle("PUT /api/v1/profile", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.updateProfile))))
mux.Handle("GET /docs", s.requireSession(http.HandlerFunc(s.docs)))
@ -134,6 +135,10 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/v1/users", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.inviteUser))))
mux.Handle("PUT /api/v1/users/{id}/access", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.setUserAccess))))
mux.Handle("POST /api/v1/users/{id}/approve", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.approveUser))))
mux.Handle("GET /api/v1/roles", s.requireSession(http.HandlerFunc(s.listRoles)))
mux.Handle("POST /api/v1/roles", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createRole))))
mux.Handle("PUT /api/v1/roles/{id}", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.updateRole))))
mux.Handle("DELETE /api/v1/roles/{id}", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.deleteRole))))
return s.recover(s.logRequests(s.securityHeaders(mux)))
}
@ -192,7 +197,7 @@ func (s *Server) openapi(w http.ResponseWriter, _ *http.Request) {
func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
name := path.Base(r.PathValue("name"))
if name != "style.css" && name != "app.js" && name != "profile.js" {
if name != "style.css" && name != "app.js" && name != "profile.js" && name != "roles.js" {
http.NotFound(w, r)
return
}
@ -400,7 +405,7 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"user": claims, "profile": s.profileResponse(profile),
"tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts,
"users": users, "pending_users": pendingUsers, "roles": role.Catalog(),
"users": users, "pending_users": pendingUsers, "permissions": role.Catalog(),
"is_system_administrator": isSystemAdministrator,
})
}
@ -623,16 +628,24 @@ func (s *Server) requireSession(next http.Handler) http.Handler {
})
}
// requireSameOrigin rejects state-changing requests that did not come from the
// page the session cookie belongs to.
//
// The comparison is made against the origin the request actually arrived on
// rather than against cfg.BaseURL alone. A deployment whose configured base URL
// disagrees with the address users browse to -- a reverse proxy terminating TLS,
// a tenant subdomain, or simply a stale GO_LOOSE_BASE_URL -- would otherwise
// reject every legitimate write. Deriving the scheme from the request keeps the
// check anchored to r.Host, which is the part an attacker cannot influence: the
// browser sets Origin itself and it always names the page's own host.
func (s *Server) requireSameOrigin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
source := r.Header.Get("Origin")
if source == "" {
source = r.Header.Get("Referer")
}
sourceURL, sourceErr := url.Parse(source)
baseURL, baseErr := url.Parse(s.cfg.BaseURL)
if sourceErr != nil || baseErr != nil || sourceURL.Scheme != baseURL.Scheme ||
!strings.EqualFold(sourceURL.Host, r.Host) {
sourceURL, err := url.Parse(source)
if err != nil || !s.originAllowed(sourceURL, r) {
writeError(w, http.StatusForbidden, "same-origin request required")
return
}
@ -640,6 +653,56 @@ func (s *Server) requireSameOrigin(next http.Handler) http.Handler {
})
}
// originAllowed reports whether source names the same origin as the request.
// The configured base URL is accepted as well so that installations which
// advertise a canonical address keep working when a request reaches the server
// through a different but still legitimate hostname.
func (s *Server) originAllowed(source *url.URL, r *http.Request) bool {
if source.Host == "" {
return false
}
baseScheme, baseHost := baseOrigin(s.cfg.BaseURL)
for _, candidate := range [][2]string{
{s.requestScheme(r), r.Host},
{baseScheme, baseHost},
} {
scheme, host := candidate[0], candidate[1]
if host == "" {
continue
}
if strings.EqualFold(source.Host, host) && strings.EqualFold(source.Scheme, scheme) {
return true
}
}
return false
}
// requestScheme reports the scheme the client used to reach this server. A
// reverse proxy terminates TLS before the request arrives, so the forwarded
// protocol takes precedence over the local connection state.
func (s *Server) requestScheme(r *http.Request) string {
if forwarded := r.Header.Get("X-Forwarded-Proto"); forwarded != "" {
if scheme, _, found := strings.Cut(forwarded, ","); found {
return strings.TrimSpace(scheme)
}
return strings.TrimSpace(forwarded)
}
if r.TLS != nil {
return "https"
}
return "http"
}
// baseOrigin splits a configured base URL into its scheme and host, returning
// empty strings when the value is missing or unparseable.
func baseOrigin(baseURL string) (scheme, host string) {
parsed, err := url.Parse(baseURL)
if err != nil {
return "", ""
}
return parsed.Scheme, parsed.Host
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")

View file

@ -8,28 +8,8 @@ import (
"github.com/danyel/go-loose/internal/key"
"github.com/danyel/go-loose/internal/password"
"github.com/danyel/go-loose/internal/role"
)
// assignableRole validates a role that an administrator may hand to an existing
// member. Any known role qualifies, including owner, so that ownership can move
// between accounts deliberately.
func assignableRole(value string) (string, bool) {
parsed, ok := role.Parse(value)
return string(parsed), ok
}
// invitableRole validates a role for a new invitation. Ownership is established
// by creating or claiming a tenant, so it is never granted here.
func invitableRole(value string) (string, bool) {
for _, item := range role.Grantable() {
if string(item) == value {
return value, true
}
}
return "", false
}
func (s *Server) configureClient(w http.ResponseWriter, r *http.Request) {
var request struct {
RedirectURIs []string `json:"redirect_uris"`
@ -79,7 +59,7 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
TenantID string `json:"tenant_id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
RoleID string `json:"role_id"`
Password string `json:"password"`
}
if !decodeJSON(w, r, &request) {
@ -95,9 +75,8 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
if request.DisplayName == "" {
request.DisplayName = request.Email
}
invitedRole, ok := invitableRole(request.Role)
if !ok {
writeError(w, http.StatusBadRequest, "role must be one of "+strings.Join(role.GrantableValues(), ", "))
if strings.TrimSpace(request.RoleID) == "" {
writeError(w, http.StatusBadRequest, "role_id is required")
return
}
passwordHash, err := password.Hash(request.Password)
@ -105,7 +84,7 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user, err := s.store.InviteUser(r.Context(), claimsFrom(r).UserID, request.TenantID, request.Email, request.DisplayName, invitedRole, passwordHash)
user, err := s.store.InviteUser(r.Context(), claimsFrom(r).UserID, request.TenantID, request.Email, request.DisplayName, request.RoleID, passwordHash)
if err != nil {
s.handleStoreError(w, "invite user", err)
return
@ -117,18 +96,17 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
func (s *Server) setUserAccess(w http.ResponseWriter, r *http.Request) {
var request struct {
TenantID string `json:"tenant_id"`
Role string `json:"role"`
RoleID string `json:"role_id"`
ApplicationIDs []string `json:"application_ids"`
}
if !decodeJSON(w, r, &request) {
return
}
membershipRole, ok := assignableRole(request.Role)
if !ok {
writeError(w, http.StatusBadRequest, "invalid role")
if strings.TrimSpace(request.RoleID) == "" {
writeError(w, http.StatusBadRequest, "role_id is required")
return
}
if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), membershipRole, request.ApplicationIDs); err != nil {
if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), request.RoleID, request.ApplicationIDs); err != nil {
s.handleStoreError(w, "update user access", err)
return
}
@ -138,20 +116,19 @@ func (s *Server) setUserAccess(w http.ResponseWriter, r *http.Request) {
func (s *Server) approveUser(w http.ResponseWriter, r *http.Request) {
var request struct {
TenantID string `json:"tenant_id"`
Role string `json:"role"`
RoleID string `json:"role_id"`
ApplicationIDs []string `json:"application_ids"`
}
if !decodeJSON(w, r, &request) {
return
}
membershipRole, ok := assignableRole(request.Role)
if !ok {
writeError(w, http.StatusBadRequest, "invalid role")
if strings.TrimSpace(request.RoleID) == "" {
writeError(w, http.StatusBadRequest, "role_id is required")
return
}
if err := s.store.ApprovePendingUser(
r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"),
membershipRole, request.ApplicationIDs,
request.RoleID, request.ApplicationIDs,
); err != nil {
s.handleStoreError(w, "approve pending user", err)
return