250 lines
9.3 KiB
Go
250 lines
9.3 KiB
Go
package controlplane
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
func (s *Server) webAuthRoute(w http.ResponseWriter, r *http.Request, correlationID string) error {
|
|
switch {
|
|
case r.URL.Path == "/v1/auth/me" && r.Method == http.MethodGet:
|
|
principal, err := s.authenticateWebPrincipal(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"user": principal.WebUser, "correlation_id": correlationID})
|
|
return nil
|
|
case r.URL.Path == "/v1/auth/logout" && r.Method == http.MethodPost:
|
|
if token := bearerToken(r); token != "" {
|
|
s.sessionMu.Lock()
|
|
delete(s.sessions, webSessionKey(token))
|
|
s.sessionMu.Unlock()
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "correlation_id": correlationID})
|
|
return nil
|
|
default:
|
|
return requestError{status: http.StatusMethodNotAllowed, code: "MethodNotAllowed", message: "The method is not allowed for this resource."}
|
|
}
|
|
}
|
|
|
|
func (s *Server) usersRoute(w http.ResponseWriter, r *http.Request, correlationID string) error {
|
|
principal, err := s.authenticateWebPrincipal(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if principal.Role != RoleAdmin {
|
|
return requestError{status: http.StatusForbidden, code: "AdminRequired", message: "Administrator access is required."}
|
|
}
|
|
parts := strings.Split(strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/users"), "/"), "/")
|
|
if len(parts) == 1 && parts[0] == "" {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
users, err := s.userStore.List(r.Context())
|
|
if err != nil {
|
|
return userStoreRequestError(err)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"users": users, "correlation_id": correlationID})
|
|
return nil
|
|
case http.MethodPost:
|
|
return s.createWebUser(w, r, principal, correlationID)
|
|
default:
|
|
return requestError{status: http.StatusMethodNotAllowed, code: "MethodNotAllowed", message: "The method is not allowed for this resource."}
|
|
}
|
|
}
|
|
if len(parts) < 1 || !validIdentifier(parts[0], 128) {
|
|
return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}
|
|
}
|
|
userID := parts[0]
|
|
if len(parts) == 2 && parts[1] == "password" {
|
|
if r.Method != http.MethodPost {
|
|
return requestError{status: http.StatusMethodNotAllowed, code: "MethodNotAllowed", message: "The method is not allowed for this resource."}
|
|
}
|
|
return s.resetWebUserPassword(w, r, principal, userID, correlationID)
|
|
}
|
|
if len(parts) != 1 {
|
|
return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
user, err := s.userStore.Get(r.Context(), userID)
|
|
if errors.Is(err, ErrUserNotFound) {
|
|
return requestError{status: http.StatusNotFound, code: "UserNotFound", message: "User was not found."}
|
|
}
|
|
if err != nil {
|
|
return userStoreRequestError(err)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"user": user, "correlation_id": correlationID})
|
|
return nil
|
|
case http.MethodPatch, http.MethodPut:
|
|
return s.updateWebUser(w, r, principal, userID, correlationID)
|
|
default:
|
|
return requestError{status: http.StatusMethodNotAllowed, code: "MethodNotAllowed", message: "The method is not allowed for this resource."}
|
|
}
|
|
}
|
|
|
|
func (s *Server) createWebUser(w http.ResponseWriter, r *http.Request, actor WebPrincipal, correlationID string) error {
|
|
var request struct {
|
|
Username string `json:"username"`
|
|
DisplayName string `json:"display_name"`
|
|
Password string `json:"password"`
|
|
Role string `json:"role"`
|
|
NodeIDs []string `json:"node_ids"`
|
|
}
|
|
if err := decodeJSON(r, &request, 16*1024); err != nil {
|
|
return err
|
|
}
|
|
if request.Role == "" {
|
|
request.Role = RoleMember
|
|
}
|
|
if request.Role == RoleMember {
|
|
if err := s.validateUserNodeIDs(request.NodeIDs); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
user, err := s.userStore.Create(r.Context(), WebUser{
|
|
Username: request.Username, DisplayName: request.DisplayName, Role: request.Role, NodeIDs: request.NodeIDs,
|
|
}, request.Password)
|
|
if errors.Is(err, ErrUserAlreadyExists) {
|
|
return requestError{status: http.StatusConflict, code: "UserAlreadyExists", message: "A user with this username already exists."}
|
|
}
|
|
if errors.Is(err, ErrInvalidUser) {
|
|
return requestError{status: http.StatusBadRequest, code: "InvalidUser", message: "User details or password do not meet the requirements."}
|
|
}
|
|
if err != nil {
|
|
return userStoreRequestError(err)
|
|
}
|
|
_ = s.appendAudit(actor.Username, "user.create", "user:"+user.ID, correlationID, "success")
|
|
writeJSON(w, http.StatusCreated, map[string]any{"user": user, "correlation_id": correlationID})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) updateWebUser(w http.ResponseWriter, r *http.Request, actor WebPrincipal, userID, correlationID string) error {
|
|
var request struct {
|
|
DisplayName *string `json:"display_name"`
|
|
Role *string `json:"role"`
|
|
Active *bool `json:"active"`
|
|
NodeIDs *[]string `json:"node_ids"`
|
|
}
|
|
if err := decodeJSON(r, &request, 16*1024); err != nil {
|
|
return err
|
|
}
|
|
current, err := s.userStore.Get(r.Context(), userID)
|
|
if errors.Is(err, ErrUserNotFound) {
|
|
return requestError{status: http.StatusNotFound, code: "UserNotFound", message: "User was not found."}
|
|
}
|
|
if err != nil {
|
|
return userStoreRequestError(err)
|
|
}
|
|
update := UserUpdate{DisplayName: current.DisplayName, Role: current.Role, Active: current.Active, NodeIDs: current.NodeIDs}
|
|
if request.DisplayName != nil {
|
|
update.DisplayName = *request.DisplayName
|
|
}
|
|
if request.Role != nil {
|
|
update.Role = *request.Role
|
|
}
|
|
if request.Active != nil {
|
|
update.Active = *request.Active
|
|
}
|
|
if request.NodeIDs != nil {
|
|
update.NodeIDs = *request.NodeIDs
|
|
}
|
|
if update.Role == RoleMember {
|
|
if err := s.validateUserNodeIDs(update.NodeIDs); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if userID == actor.ID && (update.Role != RoleAdmin || !update.Active) {
|
|
return requestError{status: http.StatusBadRequest, code: "SelfDemotionNotAllowed", message: "Administrators cannot disable or demote their own account."}
|
|
}
|
|
updated, err := s.userStore.Update(r.Context(), userID, update)
|
|
if errors.Is(err, ErrUserNotFound) {
|
|
return requestError{status: http.StatusNotFound, code: "UserNotFound", message: "User was not found."}
|
|
}
|
|
if errors.Is(err, ErrLastAdministrator) {
|
|
return requestError{status: http.StatusConflict, code: "LastAdministrator", message: "At least one active administrator must remain."}
|
|
}
|
|
if errors.Is(err, ErrInvalidUser) {
|
|
return requestError{status: http.StatusBadRequest, code: "InvalidUser", message: "User details are invalid."}
|
|
}
|
|
if err != nil {
|
|
return userStoreRequestError(err)
|
|
}
|
|
if !updated.Active {
|
|
s.revokeUserSessions(userID)
|
|
}
|
|
_ = s.appendAudit(actor.Username, "user.update", "user:"+userID, correlationID, "success")
|
|
writeJSON(w, http.StatusOK, map[string]any{"user": updated, "correlation_id": correlationID})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) resetWebUserPassword(w http.ResponseWriter, r *http.Request, actor WebPrincipal, userID, correlationID string) error {
|
|
var request struct {
|
|
Password string `json:"password"`
|
|
}
|
|
if err := decodeJSON(r, &request, 8*1024); err != nil {
|
|
return err
|
|
}
|
|
if err := s.userStore.SetPassword(r.Context(), userID, request.Password); errors.Is(err, ErrUserNotFound) {
|
|
return requestError{status: http.StatusNotFound, code: "UserNotFound", message: "User was not found."}
|
|
} else if errors.Is(err, ErrInvalidUser) {
|
|
return requestError{status: http.StatusBadRequest, code: "InvalidPassword", message: "Password must be between 12 and 256 bytes."}
|
|
} else if err != nil {
|
|
return userStoreRequestError(err)
|
|
}
|
|
s.revokeUserSessions(userID)
|
|
_ = s.appendAudit(actor.Username, "user.password_reset", "user:"+userID, correlationID, "success")
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "correlation_id": correlationID})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) validateUserNodeIDs(nodeIDs []string) error {
|
|
known := make(map[string]struct{}, len(nodeIDs))
|
|
err := s.store.Read(func(state PersistedState) error {
|
|
for nodeID := range state.Nodes {
|
|
known[nodeID] = struct{}{}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return requestError{status: http.StatusInternalServerError, code: "StoreError", message: "Unable to validate Node assignments."}
|
|
}
|
|
for _, nodeID := range uniqueNodeIDs(nodeIDs) {
|
|
if _, ok := known[nodeID]; !ok {
|
|
return requestError{status: http.StatusBadRequest, code: "UnknownNode", message: "One or more assigned Nodes do not exist."}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) revokeUserSessions(userID string) {
|
|
s.sessionMu.Lock()
|
|
for tokenHash, session := range s.sessions {
|
|
if session.UserID == userID {
|
|
delete(s.sessions, tokenHash)
|
|
}
|
|
}
|
|
s.sessionMu.Unlock()
|
|
}
|
|
|
|
func userStoreRequestError(_ error) error {
|
|
return requestError{status: http.StatusInternalServerError, code: "UserStoreError", message: "Unable to complete the user-management request."}
|
|
}
|
|
|
|
func inaccessibleResource() error {
|
|
return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}
|
|
}
|
|
|
|
func (s *Server) authorizeAccount(ctx context.Context, principal WebPrincipal, accountID string) error {
|
|
nodeID, err := s.accountStores.SourceNodeID(ctx, accountID)
|
|
if errors.Is(err, sql.ErrNoRows) || err == nil && !principal.CanAccessNode(nodeID) {
|
|
return inaccessibleResource()
|
|
}
|
|
if err != nil {
|
|
return requestError{status: http.StatusInternalServerError, code: "AccountLookupFailed", message: "Unable to verify account access."}
|
|
}
|
|
return nil
|
|
}
|