hscontrol/api/v1: implement user endpoints

CreateUser, ListUsers (with filters), RenameUser, DeleteUser over the state
layer; unknown users now 404. HTTP-parity tests via the generated client.
This commit is contained in:
Kristoffer Dalby
2026-06-17 15:23:56 +00:00
parent 1eabd70567
commit fe76fe0f57
6 changed files with 309 additions and 1 deletions
+58
View File
@@ -0,0 +1,58 @@
package apiv1
import (
oas "github.com/juanfont/headscale/gen/api/v1"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
// This file bridges the existing proto response builders (the Proto() methods
// on the state types) to the ogen API types. Reusing Proto() guarantees the
// new HTTP API surfaces exactly the same data the gRPC/gateway stack did
// (username fallback, masked key prefixes, online computation, the
// TaggedDevices substitution, …) without reimplementing it.
//
// Unlike grpc-gateway (which marshalled with EmitUnpopulated), these converters
// omit zero-value and absent fields — empty strings, false booleans, zero
// numbers, empty arrays, nil timestamps/objects, and the unspecified register
// method. See docs/v1-ogen/CHANGES.md. When the proto stack is removed, these
// converters are rewritten to read the state types directly.
//
// Converters are added here as each resource group is migrated.
func optString(s string) oas.OptString {
if s == "" {
return oas.OptString{}
}
return oas.NewOptString(s)
}
func optUint64(v uint64) oas.OptUint64 {
if v == 0 {
return oas.OptUint64{}
}
return oas.NewOptUint64(v)
}
func optTime(ts *timestamppb.Timestamp) oas.OptDateTime {
if ts == nil {
return oas.OptDateTime{}
}
return oas.NewOptDateTime(ts.AsTime())
}
func oasUser(u *v1.User) oas.User {
return oas.User{
ID: optUint64(u.GetId()),
Name: optString(u.GetName()),
CreatedAt: optTime(u.GetCreatedAt()),
DisplayName: optString(u.GetDisplayName()),
Email: optString(u.GetEmail()),
ProviderId: optString(u.GetProviderId()),
Provider: optString(u.GetProvider()),
ProfilePicUrl: optString(u.GetProfilePicUrl()),
}
}
+3 -1
View File
@@ -7,6 +7,7 @@ import (
"net/http"
oas "github.com/juanfont/headscale/gen/api/v1"
"github.com/juanfont/headscale/hscontrol/db"
"github.com/juanfont/headscale/hscontrol/state"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/rs/zerolog/log"
@@ -54,7 +55,8 @@ func internalError(detail string) *oas.ErrorStatusCode {
func mapStateError(err error) *oas.ErrorStatusCode {
switch {
case errors.Is(err, gorm.ErrRecordNotFound),
errors.Is(err, state.ErrNodeNotFound):
errors.Is(err, state.ErrNodeNotFound),
errors.Is(err, db.ErrUserNotFound):
return notFound(err.Error())
case errors.Is(err, types.ErrPolicyUpdateIsDisabled):
return badRequest(err.Error())
+112
View File
@@ -0,0 +1,112 @@
package apiv1
import (
"cmp"
"context"
"slices"
oas "github.com/juanfont/headscale/gen/api/v1"
"github.com/juanfont/headscale/hscontrol/types"
"gorm.io/gorm"
)
// CreateUser creates a user and distributes the resulting policy change.
func (s *Server) CreateUser(
_ context.Context,
req *oas.CreateUserReq,
) (*oas.CreateUserOK, error) {
newUser := types.User{
Name: req.Name.Or(""),
DisplayName: req.DisplayName.Or(""),
Email: req.Email.Or(""),
ProfilePicURL: req.PictureUrl.Or(""),
}
user, policyChanged, err := s.state.CreateUser(newUser)
if err != nil {
return nil, internalError("creating user: " + err.Error())
}
s.change(policyChanged)
return &oas.CreateUserOK{User: oas.NewOptUser(oasUser(user.Proto()))}, nil
}
// ListUsers lists users, optionally filtered by id, name, or email, sorted by id.
func (s *Server) ListUsers(
_ context.Context,
params oas.ListUsersParams,
) (*oas.ListUsersOK, error) {
var (
users []types.User
err error
)
switch {
case params.Name.Or("") != "":
users, err = s.state.ListUsersWithFilter(&types.User{Name: params.Name.Or("")})
case params.Email.Or("") != "":
users, err = s.state.ListUsersWithFilter(&types.User{Email: params.Email.Or("")})
case params.ID.Or(0) != 0:
users, err = s.state.ListUsersWithFilter(
&types.User{Model: gorm.Model{ID: uint(params.ID.Or(0))}},
)
default:
users, err = s.state.ListAllUsers()
}
if err != nil {
return nil, mapStateError(err)
}
slices.SortFunc(users, func(a, b types.User) int { return cmp.Compare(a.ID, b.ID) })
out := make([]oas.User, len(users))
for i := range users {
out[i] = oasUser(users[i].Proto())
}
return &oas.ListUsersOK{Users: out}, nil
}
// RenameUser renames a user and distributes the resulting policy change.
func (s *Server) RenameUser(
_ context.Context,
params oas.RenameUserParams,
) (*oas.RenameUserOK, error) {
oldUser, err := s.state.GetUserByID(types.UserID(params.OldID))
if err != nil {
return nil, mapStateError(err)
}
_, c, err := s.state.RenameUser(types.UserID(oldUser.ID), params.NewName)
if err != nil {
return nil, mapStateError(err)
}
s.change(c)
newUser, err := s.state.GetUserByName(params.NewName)
if err != nil {
return nil, mapStateError(err)
}
return &oas.RenameUserOK{User: oas.NewOptUser(oasUser(newUser.Proto()))}, nil
}
// DeleteUser deletes a user and distributes the resulting policy change.
func (s *Server) DeleteUser(_ context.Context, params oas.DeleteUserParams) error {
user, err := s.state.GetUserByID(types.UserID(params.ID))
if err != nil {
return mapStateError(err)
}
policyChanged, err := s.state.DeleteUser(types.UserID(user.ID))
if err != nil {
return mapStateError(err)
}
s.change(policyChanged)
return nil
}