Gonçalo Rodrigues 13b7149614 First Commit
2026-06-13 11:25:23 +01:00

605 lines
17 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"homelab/pkg/auth"
"homelab/pkg/mongo"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"go.mongodb.org/mongo-driver/v2/bson"
mg "go.mongodb.org/mongo-driver/v2/mongo"
)
var tracer = otel.Tracer("users")
type User struct {
ID string `bson:"_id" json:"id"`
Email string `bson:"email" json:"email"`
Password string `bson:"password" json:"-"`
Roles []string `bson:"roles" json:"roles"`
}
type Invite struct {
Code string `bson:"_id" json:"code"`
UsedBy string `bson:"used_by,omitempty" json:"used_by,omitempty"`
}
type Role struct {
ID string `bson:"_id" json:"id"`
Name string `bson:"name" json:"name"`
Description string `bson:"description" json:"description"`
Permissions []string `bson:"permissions" json:"permissions"`
}
type Handler struct {
users *mg.Collection
invites *mg.Collection
roles *mg.Collection
}
func NewHandler(db *mongo.DB) *Handler {
return &Handler{
users: db.Collection("users"),
invites: db.Collection("invites"),
roles: db.Collection("roles"),
}
}
func (h *Handler) SeedAdmin(ctx context.Context) error {
ctx, span := tracer.Start(ctx, "SeedAdmin")
defer span.End()
count, err := h.users.CountDocuments(ctx, bson.M{})
if err != nil {
return fmt.Errorf("count users: %w", err)
}
if count > 0 {
slog.InfoContext(ctx, "users exist, skipping admin seed")
return nil
}
email := os.Getenv("ADMIN_EMAIL")
password := os.Getenv("ADMIN_PASSWORD")
if email == "" || password == "" {
return fmt.Errorf("ADMIN_EMAIL and ADMIN_PASSWORD must be set when no users exist")
}
hash, err := auth.HashPassword(password)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
user := User{
ID: bson.NewObjectID().Hex(),
Email: email,
Password: hash,
Roles: []string{"admin", "user"},
}
if _, err := h.users.InsertOne(ctx, user); err != nil {
return fmt.Errorf("insert admin: %w", err)
}
span.SetAttributes(attribute.String("admin.email", email))
slog.InfoContext(ctx, "seeded admin user", "email", email)
return nil
}
func (h *Handler) SeedRoles(ctx context.Context) error {
ctx, span := tracer.Start(ctx, "SeedRoles")
defer span.End()
count, err := h.roles.CountDocuments(ctx, bson.M{})
if err != nil {
return fmt.Errorf("count roles: %w", err)
}
if count > 0 {
slog.InfoContext(ctx, "roles exist, skipping seed")
return nil
}
defaults := []Role{
{
ID: bson.NewObjectID().Hex(),
Name: "admin",
Description: "Full access to all services and management",
Permissions: []string{"service:*", "users:manage", "roles:manage"},
},
{
ID: bson.NewObjectID().Hex(),
Name: "user",
Description: "Default user with basic access",
Permissions: []string{"service:home:access"},
},
{
ID: bson.NewObjectID().Hex(),
Name: "grafana-reader",
Description: "Access to Grafana dashboards",
Permissions: []string{"service:grafana:access"},
},
{
ID: bson.NewObjectID().Hex(),
Name: "jaeger-reader",
Description: "Access to Jaeger tracing UI",
Permissions: []string{"service:jaeger:access"},
},
}
for _, r := range defaults {
if _, err := h.roles.InsertOne(ctx, r); err != nil {
return fmt.Errorf("insert role %s: %w", r.Name, err)
}
}
span.SetAttributes(attribute.Int("roles.count", len(defaults)))
slog.InfoContext(ctx, "seeded default roles", "count", len(defaults))
return nil
}
func (h *Handler) ResolvePermissions(ctx context.Context, roleNames []string) ([]string, error) {
ctx, span := tracer.Start(ctx, "ResolvePermissions",
trace.WithAttributes(attribute.StringSlice("roles", roleNames)),
)
defer span.End()
if len(roleNames) == 0 {
return nil, nil
}
cursor, err := h.roles.Find(ctx, bson.M{"name": bson.M{"$in": roleNames}})
if err != nil {
return nil, fmt.Errorf("find roles: %w", err)
}
defer cursor.Close(ctx)
seen := map[string]bool{}
var result []string
for cursor.Next(ctx) {
var r Role
if err := cursor.Decode(&r); err != nil {
return nil, fmt.Errorf("decode role: %w", err)
}
for _, p := range r.Permissions {
if !seen[p] {
seen[p] = true
result = append(result, p)
}
}
}
if r := cursor.Err(); r != nil {
return nil, fmt.Errorf("cursor: %w", r)
}
span.SetAttributes(attribute.Int("permissions.count", len(result)))
return result, nil
}
// --- User-facing handlers ---
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "Register")
defer span.End()
var body struct {
Email string `json:"email"`
Password string `json:"password"`
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
span.RecordError(err)
slog.WarnContext(ctx, "register: invalid body")
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
span.SetAttributes(attribute.String("register.email", body.Email))
var inv Invite
if err := h.invites.FindOne(ctx, bson.M{"_id": body.Code}).Decode(&inv); err != nil {
span.SetAttributes(attribute.String("register.result", "invalid_invite"))
slog.WarnContext(ctx, "register: invalid invite code", "email", body.Email)
http.Error(w, "invalid invite code", http.StatusForbidden)
return
}
if inv.UsedBy != "" {
span.SetAttributes(attribute.String("register.result", "invite_used"))
slog.WarnContext(ctx, "register: invite already used", "code", body.Code)
http.Error(w, "invite code already used", http.StatusForbidden)
return
}
hash, err := auth.HashPassword(body.Password)
if err != nil {
span.RecordError(err)
slog.ErrorContext(ctx, "register: hash password", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
user := User{
ID: bson.NewObjectID().Hex(),
Email: body.Email,
Password: hash,
Roles: []string{"user"},
}
if _, err := h.users.InsertOne(ctx, user); err != nil {
span.SetAttributes(attribute.String("register.result", "email_conflict"))
slog.WarnContext(ctx, "register: email already exists", "email", body.Email)
http.Error(w, "email already registered", http.StatusConflict)
return
}
h.invites.UpdateOne(ctx, bson.M{"_id": body.Code}, bson.M{"$set": bson.M{"used_by": user.ID}})
span.SetAttributes(attribute.String("register.result", "success"), attribute.String("user.id", user.ID))
slog.InfoContext(ctx, "user registered", "email", body.Email, "id", user.ID)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "GetUser")
defer span.End()
id := r.PathValue("id")
span.SetAttributes(attribute.String("user.id", id))
var user User
if err := h.users.FindOne(ctx, bson.M{"_id": id}).Decode(&user); err != nil {
span.SetAttributes(attribute.String("get.result", "not_found"))
http.Error(w, "not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(user)
}
func (h *Handler) GetUserByEmail(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "GetUserByEmail")
defer span.End()
email := r.PathValue("email")
span.SetAttributes(attribute.String("user.email", email))
var user User
if err := h.users.FindOne(ctx, bson.M{"email": email}).Decode(&user); err != nil {
span.SetAttributes(attribute.String("get.result", "not_found"))
http.Error(w, "not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(user)
}
func (h *Handler) CreateInvite(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "CreateInvite")
defer span.End()
code := auth.GenerateCode()
_, err := h.invites.InsertOne(ctx, Invite{Code: code, UsedBy: ""})
if err != nil {
span.RecordError(err)
slog.ErrorContext(ctx, "create invite", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
span.SetAttributes(attribute.String("invite.code", code))
slog.InfoContext(ctx, "invite created", "code", code)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"code": code})
}
func (h *Handler) VerifyPassword(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "VerifyPassword")
defer span.End()
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
span.RecordError(err)
slog.WarnContext(ctx, "verify: invalid body")
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
span.SetAttributes(attribute.String("verify.email", body.Email))
var user User
if err := h.users.FindOne(ctx, bson.M{"email": body.Email}).Decode(&user); err != nil {
span.SetAttributes(attribute.String("verify.result", "user_not_found"))
slog.WarnContext(ctx, "verify: user not found", "email", body.Email)
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
if !auth.CheckPassword(body.Password, user.Password) {
span.SetAttributes(attribute.String("verify.result", "wrong_password"))
slog.WarnContext(ctx, "verify: wrong password", "email", body.Email)
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
perms, err := h.ResolvePermissions(ctx, user.Roles)
if err != nil {
slog.ErrorContext(ctx, "verify: resolve permissions", "err", err)
}
span.SetAttributes(
attribute.String("verify.result", "success"),
attribute.String("user.id", user.ID),
attribute.StringSlice("user.roles", user.Roles),
attribute.Int("permissions.count", len(perms)),
)
slog.InfoContext(ctx, "password verified", "email", body.Email, "id", user.ID, "roles", user.Roles)
json.NewEncoder(w).Encode(struct {
ID string `json:"id"`
Email string `json:"email"`
Roles []string `json:"roles"`
Permissions []string `json:"permissions"`
}{user.ID, user.Email, user.Roles, perms})
}
// --- Admin handlers ---
func (h *Handler) AdminListRoles(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "AdminListRoles")
defer span.End()
cur, err := h.roles.Find(ctx, bson.M{})
if err != nil {
span.RecordError(err)
slog.ErrorContext(ctx, "admin: list roles", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
defer cur.Close(ctx)
var roles []Role
cur.All(ctx, &roles)
if roles == nil {
roles = []Role{}
}
span.SetAttributes(attribute.Int("roles.count", len(roles)))
json.NewEncoder(w).Encode(roles)
}
func (h *Handler) AdminCreateRole(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "AdminCreateRole")
defer span.End()
var role Role
if err := json.NewDecoder(r.Body).Decode(&role); err != nil {
span.RecordError(err)
slog.WarnContext(ctx, "admin: create role invalid body")
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if role.Name == "" {
http.Error(w, "name is required", http.StatusBadRequest)
return
}
role.ID = bson.NewObjectID().Hex()
if _, err := h.roles.InsertOne(ctx, role); err != nil {
span.SetAttributes(attribute.String("role.name", role.Name))
http.Error(w, "role already exists", http.StatusConflict)
return
}
span.SetAttributes(attribute.String("role.name", role.Name), attribute.String("role.id", role.ID))
slog.InfoContext(ctx, "role created", "name", role.Name, "id", role.ID)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(role)
}
func (h *Handler) AdminGetRole(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "AdminGetRole")
defer span.End()
id := r.PathValue("id")
span.SetAttributes(attribute.String("role.id", id))
var role Role
if err := h.roles.FindOne(ctx, bson.M{"_id": id}).Decode(&role); err != nil {
span.SetAttributes(attribute.String("get.result", "not_found"))
http.Error(w, "not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(role)
}
func (h *Handler) AdminUpdateRole(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "AdminUpdateRole")
defer span.End()
id := r.PathValue("id")
span.SetAttributes(attribute.String("role.id", id))
var body struct {
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
span.RecordError(err)
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
update := bson.M{}
if body.Name != "" {
update["name"] = body.Name
}
if body.Description != "" {
update["description"] = body.Description
}
if body.Permissions != nil {
update["permissions"] = body.Permissions
}
result, err := h.roles.UpdateOne(ctx, bson.M{"_id": id}, bson.M{"$set": update})
if err != nil {
span.RecordError(err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if result.MatchedCount == 0 {
span.SetAttributes(attribute.String("update.result", "not_found"))
http.Error(w, "not found", http.StatusNotFound)
return
}
span.SetAttributes(attribute.String("update.result", "success"))
slog.InfoContext(ctx, "role updated", "id", id)
h.AdminGetRole(w, r)
}
func (h *Handler) AdminDeleteRole(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "AdminDeleteRole")
defer span.End()
id := r.PathValue("id")
span.SetAttributes(attribute.String("role.id", id))
result, err := h.roles.DeleteOne(ctx, bson.M{"_id": id})
if err != nil {
span.RecordError(err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if result.DeletedCount == 0 {
span.SetAttributes(attribute.String("delete.result", "not_found"))
http.Error(w, "not found", http.StatusNotFound)
return
}
span.SetAttributes(attribute.String("delete.result", "success"))
slog.InfoContext(ctx, "role deleted", "id", id)
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) AdminCreateUser(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "AdminCreateUser")
defer span.End()
var body struct {
Email string `json:"email"`
Password string `json:"password"`
Roles []string `json:"roles"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
span.RecordError(err)
slog.WarnContext(ctx, "admin: create user invalid body")
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
span.SetAttributes(attribute.String("user.email", body.Email))
if body.Email == "" || body.Password == "" {
http.Error(w, "email and password are required", http.StatusBadRequest)
return
}
hash, err := auth.HashPassword(body.Password)
if err != nil {
span.RecordError(err)
slog.ErrorContext(ctx, "admin: hash password", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
user := User{
ID: bson.NewObjectID().Hex(),
Email: body.Email,
Password: hash,
Roles: body.Roles,
}
if user.Roles == nil {
user.Roles = []string{"user"}
}
if _, err := h.users.InsertOne(ctx, user); err != nil {
span.SetAttributes(attribute.String("create.result", "email_conflict"))
slog.WarnContext(ctx, "admin: email already exists", "email", body.Email)
http.Error(w, "email already registered", http.StatusConflict)
return
}
span.SetAttributes(attribute.String("create.result", "success"), attribute.String("user.id", user.ID))
slog.InfoContext(ctx, "admin created user", "email", body.Email, "id", user.ID, "roles", user.Roles)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
func (h *Handler) AdminListUsers(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "AdminListUsers")
defer span.End()
cur, err := h.users.Find(ctx, bson.M{})
if err != nil {
span.RecordError(err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
defer cur.Close(ctx)
var users []User
cur.All(ctx, &users)
if users == nil {
users = []User{}
}
type userView struct {
ID string `json:"id"`
Email string `json:"email"`
Roles []string `json:"roles"`
}
view := make([]userView, len(users))
for i, u := range users {
view[i] = userView{ID: u.ID, Email: u.Email, Roles: u.Roles}
}
span.SetAttributes(attribute.Int("users.count", len(view)))
json.NewEncoder(w).Encode(view)
}
func (h *Handler) AdminUpdateUser(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "AdminUpdateUser")
defer span.End()
id := r.PathValue("id")
span.SetAttributes(attribute.String("user.id", id))
var body struct {
Roles []string `json:"roles"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
span.RecordError(err)
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
result, err := h.users.UpdateOne(ctx, bson.M{"_id": id}, bson.M{"$set": bson.M{"roles": body.Roles}})
if err != nil {
span.RecordError(err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if result.MatchedCount == 0 {
http.Error(w, "not found", http.StatusNotFound)
return
}
span.SetAttributes(attribute.StringSlice("new_roles", body.Roles))
slog.InfoContext(ctx, "user roles updated", "id", id, "roles", body.Roles)
h.GetUser(w, r)
}