fix installer
This commit is contained in:
parent
318fb51085
commit
6319da650c
31 changed files with 2391 additions and 87 deletions
|
|
@ -8,9 +8,35 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/danyel/go-loose/internal/key"
|
||||
"github.com/danyel/go-loose/internal/role"
|
||||
"github.com/danyel/go-loose/internal/store"
|
||||
)
|
||||
|
||||
// clientUserResponse is the identity payload handed to client applications. It
|
||||
// carries the tenant role and its capabilities so that an application such as
|
||||
// go-tell can gate features, plus an absolute avatar_url it can render directly.
|
||||
type clientUserResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
ApplicationID string `json:"application_id"`
|
||||
Application string `json:"application"`
|
||||
Role string `json:"role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
func (s *Server) clientUser(user store.ClientUser) clientUserResponse {
|
||||
return 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(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) clientAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
clientID, redirectURI, state := query.Get("client_id"), query.Get("redirect_uri"), query.Get("state")
|
||||
|
|
@ -99,7 +125,7 @@ func (s *Server) clientToken(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"access_token": token, "token_type": "Bearer",
|
||||
"expires_in": int(time.Until(expiresAt).Seconds()), "user": user,
|
||||
"expires_in": int(time.Until(expiresAt).Seconds()), "user": s.clientUser(user),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -118,7 +144,7 @@ func (s *Server) clientUserInfo(w http.ResponseWriter, r *http.Request) {
|
|||
s.internalError(w, "load client user", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
writeJSON(w, http.StatusOK, s.clientUser(user))
|
||||
}
|
||||
|
||||
func (s *Server) clientLogout(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
143
internal/server/profile.go
Normal file
143
internal/server/profile.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/danyel/go-loose/internal/avatar"
|
||||
"github.com/danyel/go-loose/internal/store"
|
||||
)
|
||||
|
||||
// maxProfileBody bounds a profile update. It allows an avatar at its 2 MiB
|
||||
// limit after base64 inflation plus the JSON envelope.
|
||||
const maxProfileBody = 4 << 20
|
||||
|
||||
// maxDisplayName bounds a self-selected display name.
|
||||
const maxDisplayName = 200
|
||||
|
||||
type profileResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
Memberships []store.Membership `json:"memberships"`
|
||||
IsSystemAdministrator bool `json:"is_system_administrator"`
|
||||
NameCustomized bool `json:"name_customized"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastLoginAt time.Time `json:"last_login_at"`
|
||||
ProfileUpdatedAt *time.Time `json:"profile_updated_at,omitempty"`
|
||||
AvatarUpdatedAt *time.Time `json:"avatar_updated_at,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) profilePage(w http.ResponseWriter, _ *http.Request) {
|
||||
s.serveEmbedded(w, "profile.html")
|
||||
}
|
||||
|
||||
func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
|
||||
profile, err := s.store.Profile(r.Context(), claimsFrom(r).UserID)
|
||||
if err != nil {
|
||||
s.handleStoreError(w, "load profile", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.profileResponse(profile))
|
||||
}
|
||||
|
||||
func (s *Server) updateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
DisplayName *string `json:"display_name"`
|
||||
Avatar string `json:"avatar"`
|
||||
RemoveAvatar bool `json:"remove_avatar"`
|
||||
}
|
||||
if !decodeJSONLimit(w, r, maxProfileBody, &request) {
|
||||
return
|
||||
}
|
||||
update := store.ProfileUpdate{}
|
||||
if request.DisplayName != nil {
|
||||
name := strings.TrimSpace(*request.DisplayName)
|
||||
if name == "" || len(name) > maxDisplayName {
|
||||
writeError(w, http.StatusBadRequest, "display name must be between 1 and 200 characters")
|
||||
return
|
||||
}
|
||||
update.DisplayName = &name
|
||||
}
|
||||
switch {
|
||||
case request.RemoveAvatar && request.Avatar != "":
|
||||
writeError(w, http.StatusBadRequest, "send either a new picture or remove_avatar, not both")
|
||||
return
|
||||
case request.RemoveAvatar:
|
||||
update.RemoveAvatar = true
|
||||
case strings.TrimSpace(request.Avatar) != "":
|
||||
data, contentType, err := avatar.Parse(request.Avatar)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
key, err := avatar.NewKey()
|
||||
if err != nil {
|
||||
s.internalError(w, "generate picture key", err)
|
||||
return
|
||||
}
|
||||
update.AvatarKey, update.AvatarData, update.ContentType = key, data, contentType
|
||||
case update.DisplayName == nil:
|
||||
writeError(w, http.StatusBadRequest, "a display name or a picture is required")
|
||||
return
|
||||
}
|
||||
if err := s.store.UpdateProfile(r.Context(), claimsFrom(r).UserID, update); err != nil {
|
||||
s.handleStoreError(w, "update profile", err)
|
||||
return
|
||||
}
|
||||
profile, err := s.store.Profile(r.Context(), claimsFrom(r).UserID)
|
||||
if err != nil {
|
||||
s.handleStoreError(w, "reload profile", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.profileResponse(profile))
|
||||
}
|
||||
|
||||
// avatar serves a stored profile picture by its unguessable key.
|
||||
//
|
||||
// The route is deliberately public. A profile picture is rendered by other
|
||||
// applications such as go-tell through a plain image tag, which cannot carry
|
||||
// the authentication cookie, and the key is 256 bits of randomness rather than
|
||||
// a user identifier. Nothing else about the user is exposed here.
|
||||
func (s *Server) avatar(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.PathValue("key")
|
||||
if !avatar.ValidKey(key) {
|
||||
writeError(w, http.StatusNotFound, "picture not found")
|
||||
return
|
||||
}
|
||||
picture, err := s.store.Picture(r.Context(), key)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "picture not found")
|
||||
return
|
||||
}
|
||||
s.internalError(w, "load profile picture", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", picture.ContentType)
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
w.Header().Set("ETag", avatar.ETag(picture.Data))
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
|
||||
http.ServeContent(w, r, "", picture.UpdatedAt, bytes.NewReader(picture.Data))
|
||||
}
|
||||
|
||||
func (s *Server) profileResponse(profile store.Profile) profileResponse {
|
||||
return profileResponse{
|
||||
ID: profile.ID, Email: profile.Email, DisplayName: profile.DisplayName,
|
||||
AvatarURL: s.avatarURL(profile.AvatarKey), Memberships: profile.Memberships,
|
||||
IsSystemAdministrator: profile.IsSystemAdministrator, NameCustomized: profile.NameCustomized,
|
||||
CreatedAt: profile.CreatedAt, LastLoginAt: profile.LastLoginAt,
|
||||
ProfileUpdatedAt: profile.ProfileUpdatedAt, AvatarUpdatedAt: profile.AvatarUpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) avatarURL(key *string) string {
|
||||
if key == nil || *key == "" {
|
||||
return ""
|
||||
}
|
||||
return s.cfg.BaseURL + "/api/v1/avatars/" + *key
|
||||
}
|
||||
194
internal/server/profile_test.go
Normal file
194
internal/server/profile_test.go
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/danyel/go-loose/internal/config"
|
||||
"github.com/danyel/go-loose/internal/role"
|
||||
"github.com/danyel/go-loose/internal/session"
|
||||
"github.com/danyel/go-loose/internal/store"
|
||||
"github.com/danyel/go-loose/web"
|
||||
)
|
||||
|
||||
func profileTestServer() *Server {
|
||||
return &Server{cfg: config.Config{BaseURL: "https://auth.example.test"}}
|
||||
}
|
||||
|
||||
func profileRequest(t *testing.T, body string) *http.Request {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(http.MethodPut, "https://auth.example.test/api/v1/profile", strings.NewReader(body))
|
||||
claims := session.Claims{UserID: "user-1", Management: true}
|
||||
return request.WithContext(context.WithValue(request.Context(), claimsKey, claims))
|
||||
}
|
||||
|
||||
func TestAvatarURLIsAbsoluteAndOmittedWithoutKey(t *testing.T) {
|
||||
server := profileTestServer()
|
||||
if got := server.avatarURL(nil); got != "" {
|
||||
t.Fatalf("avatarURL(nil) = %q, want empty", got)
|
||||
}
|
||||
empty := ""
|
||||
if got := server.avatarURL(&empty); got != "" {
|
||||
t.Fatalf("avatarURL(empty) = %q, want empty", got)
|
||||
}
|
||||
key := "k"
|
||||
got := server.avatarURL(&key)
|
||||
if want := "https://auth.example.test/api/v1/avatars/k"; got != want {
|
||||
t.Fatalf("avatarURL() = %q, want %q", got, want)
|
||||
}
|
||||
parsed, err := url.Parse(got)
|
||||
if err != nil || parsed.Path != "/api/v1/avatars/k" {
|
||||
t.Fatalf("avatar url = %q, err = %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
if payload.Role != "operator" {
|
||||
t.Fatalf("role = %q", payload.Role)
|
||||
}
|
||||
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 !strings.HasSuffix(payload.AvatarURL, "/api/v1/avatars/avatar-key") {
|
||||
t.Fatalf("avatar url = %q", payload.AvatarURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUserWithoutMembershipIsLeastPrivileged(t *testing.T) {
|
||||
server := profileTestServer()
|
||||
payload := server.clientUser(store.ClientUser{Role: string(role.User)})
|
||||
if payload.AvatarURL != "" {
|
||||
t.Fatalf("avatar url = %q, want empty", payload.AvatarURL)
|
||||
}
|
||||
if !slices.Equal(payload.Permissions, []string{string(role.EditProfile)}) {
|
||||
t.Fatalf("permissions = %v, want only profile.edit", payload.Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvatarRejectsMalformedKeyWithoutQueryingTheDatabase(t *testing.T) {
|
||||
server := profileTestServer()
|
||||
for _, key := range []string{"", "short", "../../etc/passwd", strings.Repeat("a", 44)} {
|
||||
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/api/v1/avatars/"+key, nil)
|
||||
request.SetPathValue("key", key)
|
||||
response := httptest.NewRecorder()
|
||||
server.avatar(response, request)
|
||||
if response.Code != http.StatusNotFound {
|
||||
t.Fatalf("avatar(%q) status = %d, want %d", key, response.Code, http.StatusNotFound)
|
||||
}
|
||||
if body := response.Body.String(); strings.Contains(body, "avatar") {
|
||||
t.Fatalf("avatar(%q) leaked internals: %s", key, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfileRejectsInvalidRequests(t *testing.T) {
|
||||
server := profileTestServer()
|
||||
picture := base64.StdEncoding.EncodeToString(tinyPNG(t))
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "unknown field", body: `{"display_name":"Ana","admin":true}`},
|
||||
{name: "two JSON objects", body: `{"display_name":"Ana"}{"display_name":"Bo"}`},
|
||||
{name: "blank display name", body: `{"display_name":" "}`},
|
||||
{name: "oversized display name", body: `{"display_name":"` + strings.Repeat("a", maxDisplayName+1) + `"}`},
|
||||
{name: "picture and removal", body: `{"avatar":"` + picture + `","remove_avatar":true}`},
|
||||
{name: "picture is not base64", body: `{"avatar":"!!!not base64!!!"}`},
|
||||
{name: "picture is not an image", body: `{"avatar":"` + base64.StdEncoding.EncodeToString([]byte("<svg/>")) + `"}`},
|
||||
{name: "picture media type mismatch", body: `{"avatar":"data:image/gif;base64,` + picture + `"}`},
|
||||
{name: "nothing to update", body: `{}`},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
server.updateProfile(response, profileRequest(t, test.body))
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d (body %s)", response.Code, http.StatusBadRequest, response.Body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfileRejectsOversizedBody(t *testing.T) {
|
||||
server := profileTestServer()
|
||||
body := `{"avatar":"` + strings.Repeat("A", maxProfileBody) + `"}`
|
||||
response := httptest.NewRecorder()
|
||||
server.updateProfile(response, profileRequest(t, body))
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatalf("profile.html must be embedded: %v", err)
|
||||
}
|
||||
for _, marker := range []string{"avatar-file", "profile-form", "/assets/profile.js"} {
|
||||
if !bytes.Contains(content, []byte(marker)) {
|
||||
t.Fatalf("profile.html is missing %q", marker)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tinyPNG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||
canvas.Set(0, 0, color.RGBA{R: 255, A: 255})
|
||||
buffer := &bytes.Buffer{}
|
||||
if err := png.Encode(buffer, canvas); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import (
|
|||
"github.com/danyel/go-loose/internal/config"
|
||||
"github.com/danyel/go-loose/internal/contract"
|
||||
"github.com/danyel/go-loose/internal/key"
|
||||
"github.com/danyel/go-loose/internal/role"
|
||||
"github.com/danyel/go-loose/internal/secretbox"
|
||||
"github.com/danyel/go-loose/internal/session"
|
||||
"github.com/danyel/go-loose/internal/store"
|
||||
|
|
@ -107,6 +108,7 @@ func (s *Server) Handler() http.Handler {
|
|||
mux.HandleFunc("POST /auth/logout", s.authLogout)
|
||||
mux.HandleFunc("POST /auth/password", s.passwordLogin)
|
||||
mux.HandleFunc("GET /assets/{name}", s.asset)
|
||||
mux.HandleFunc("GET /api/v1/avatars/{key}", s.avatar)
|
||||
mux.HandleFunc("POST /api/v1/authorize", s.authorize)
|
||||
mux.HandleFunc("GET /connect/authorize", s.clientAuthorize)
|
||||
mux.HandleFunc("POST /connect/token", s.clientToken)
|
||||
|
|
@ -117,6 +119,9 @@ func (s *Server) Handler() http.Handler {
|
|||
// {$} matches only "/". The unanchored pattern "GET /" is a catch-all, so a
|
||||
// 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 /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)))
|
||||
mux.Handle("GET /openapi.json", s.requireSession(http.HandlerFunc(s.openapi)))
|
||||
mux.Handle("GET /api/v1/dashboard", s.requireSession(http.HandlerFunc(s.dashboard)))
|
||||
|
|
@ -155,16 +160,26 @@ func (s *Server) clientLoginPage(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
|
||||
allowed, err := s.store.HasManagementAccess(r.Context(), claimsFrom(r).UserID)
|
||||
claims := claimsFrom(r)
|
||||
console, err := s.store.HasConsoleAccess(r.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
s.internalError(w, "check management access", err)
|
||||
s.internalError(w, "check console access", err)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
s.serveEmbedded(w, "waiting-room.html")
|
||||
if console {
|
||||
s.serveEmbedded(w, "index.html")
|
||||
return
|
||||
}
|
||||
s.serveEmbedded(w, "index.html")
|
||||
member, err := s.store.HasMembership(r.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
s.internalError(w, "check membership", err)
|
||||
return
|
||||
}
|
||||
if member {
|
||||
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.serveEmbedded(w, "waiting-room.html")
|
||||
}
|
||||
|
||||
func (s *Server) docs(w http.ResponseWriter, _ *http.Request) {
|
||||
|
|
@ -177,7 +192,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" {
|
||||
if name != "style.css" && name != "app.js" && name != "profile.js" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
|
@ -330,13 +345,13 @@ func (s *Server) authLogout(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
claims := claimsFrom(r)
|
||||
allowed, err := s.store.HasManagementAccess(r.Context(), claims.UserID)
|
||||
allowed, err := s.store.HasConsoleAccess(r.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
s.internalError(w, "check management access", err)
|
||||
s.internalError(w, "check console access", err)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
writeError(w, http.StatusForbidden, "waiting for administrator approval")
|
||||
writeError(w, http.StatusForbidden, "this account has no console access")
|
||||
return
|
||||
}
|
||||
tenants, err := s.store.ListTenants(r.Context(), claims.UserID)
|
||||
|
|
@ -374,9 +389,19 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
|
|||
s.internalError(w, "check system administrator", err)
|
||||
return
|
||||
}
|
||||
profile, err := s.store.Profile(r.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
s.internalError(w, "load profile", err)
|
||||
return
|
||||
}
|
||||
for index := range users {
|
||||
users[index].AvatarURL = s.avatarURL(users[index].AvatarKey)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"user": claims, "tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts,
|
||||
"users": users, "pending_users": pendingUsers, "is_system_administrator": isSystemAdministrator,
|
||||
"user": claims, "profile": s.profileResponse(profile),
|
||||
"tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts,
|
||||
"users": users, "pending_users": pendingUsers, "roles": role.Catalog(),
|
||||
"is_system_administrator": isSystemAdministrator,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -703,7 +728,11 @@ func validSlug(value string) bool {
|
|||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 6<<20)
|
||||
return decodeJSONLimit(w, r, 6<<20, target)
|
||||
}
|
||||
|
||||
func decodeJSONLimit(w http.ResponseWriter, r *http.Request, limit int64, target any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
|
|
|
|||
|
|
@ -138,12 +138,14 @@ func (s *Server) renderManagementLogin(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
tenantSlug := s.requestTenantSlug(r.Host)
|
||||
tenantLogin := tenantSlug != ""
|
||||
loginPrompt := "admin@goloose:~"
|
||||
loginContext := "SYSTEM ADMINISTRATION"
|
||||
loginMessage := "Sign in through the configured identity provider. System administration is not tied to a tenant."
|
||||
passwordButton := "LOGIN"
|
||||
if tenantLogin {
|
||||
loginContext = strings.ToUpper(tenantSlug) + " · TENANT ADMINISTRATION"
|
||||
loginMessage = "Sign in with your tenant database account."
|
||||
loginPrompt = tenantSlug + "@goloose:~"
|
||||
passwordButton = "LOGIN TO " + strings.ToUpper(tenantSlug)
|
||||
}
|
||||
page := strings.NewReplacer(
|
||||
|
|
@ -153,6 +155,7 @@ func (s *Server) renderManagementLogin(w http.ResponseWriter, r *http.Request) {
|
|||
"{{PASSWORD_BUTTON}}", html.EscapeString(passwordButton),
|
||||
"{{LOCAL_STYLE}}", hiddenStyle(!tenantLogin || !s.cfg.LocalLogin),
|
||||
"{{SSO_STYLE}}", hiddenStyle(tenantLogin || !s.hasOIDC()),
|
||||
"{{LOGIN_PROMPT}}", loginPrompt,
|
||||
).Replace(string(content))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(page))
|
||||
|
|
|
|||
|
|
@ -8,8 +8,28 @@ 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"`
|
||||
|
|
@ -75,8 +95,9 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
|
|||
if request.DisplayName == "" {
|
||||
request.DisplayName = request.Email
|
||||
}
|
||||
if request.Role != "admin" && request.Role != "viewer" {
|
||||
writeError(w, http.StatusBadRequest, "role must be admin or viewer")
|
||||
invitedRole, ok := invitableRole(request.Role)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "role must be one of "+strings.Join(role.GrantableValues(), ", "))
|
||||
return
|
||||
}
|
||||
passwordHash, err := password.Hash(request.Password)
|
||||
|
|
@ -84,11 +105,12 @@ 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, request.Role, passwordHash)
|
||||
user, err := s.store.InviteUser(r.Context(), claimsFrom(r).UserID, request.TenantID, request.Email, request.DisplayName, invitedRole, passwordHash)
|
||||
if err != nil {
|
||||
s.handleStoreError(w, "invite user", err)
|
||||
return
|
||||
}
|
||||
user.AvatarURL = s.avatarURL(user.AvatarKey)
|
||||
writeJSON(w, http.StatusCreated, user)
|
||||
}
|
||||
|
||||
|
|
@ -101,11 +123,12 @@ func (s *Server) setUserAccess(w http.ResponseWriter, r *http.Request) {
|
|||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" {
|
||||
membershipRole, ok := assignableRole(request.Role)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid role")
|
||||
return
|
||||
}
|
||||
if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), request.Role, request.ApplicationIDs); err != nil {
|
||||
if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), membershipRole, request.ApplicationIDs); err != nil {
|
||||
s.handleStoreError(w, "update user access", err)
|
||||
return
|
||||
}
|
||||
|
|
@ -121,13 +144,14 @@ func (s *Server) approveUser(w http.ResponseWriter, r *http.Request) {
|
|||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" {
|
||||
membershipRole, ok := assignableRole(request.Role)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid role")
|
||||
return
|
||||
}
|
||||
if err := s.store.ApprovePendingUser(
|
||||
r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"),
|
||||
request.Role, request.ApplicationIDs,
|
||||
membershipRole, request.ApplicationIDs,
|
||||
); err != nil {
|
||||
s.handleStoreError(w, "approve pending user", err)
|
||||
return
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue