roles
This commit is contained in:
parent
6319da650c
commit
3fdc8cb489
26 changed files with 2245 additions and 488 deletions
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
151
internal/server/roles.go
Normal 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
|
||||
}
|
||||
177
internal/server/roles_test.go
Normal file
177
internal/server/roles_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue