Record project continuation changes
This commit is contained in:
@@ -14,12 +14,13 @@ const (
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Email string
|
||||
PasswordHash string
|
||||
MFAEnabled bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
MFAEnabled bool `json:"mfa_enabled"`
|
||||
PlatformRole string `json:"platform_role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
@@ -40,7 +41,7 @@ type AuthSession struct {
|
||||
ID string
|
||||
UserID string
|
||||
DeviceID string
|
||||
RefreshTokenHash string
|
||||
RefreshTokenHash string `json:"-"`
|
||||
RefreshExpiresAt time.Time
|
||||
LastSeenAt *time.Time
|
||||
LastRotatedAt *time.Time
|
||||
@@ -69,6 +70,13 @@ type BootstrapOwnerCommand struct {
|
||||
ActivationSignature string `json:"activation_signature"`
|
||||
}
|
||||
|
||||
type CreateUserCommand struct {
|
||||
ActorUserID string `json:"actor_user_id"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
PlatformRole string `json:"platform_role"`
|
||||
}
|
||||
|
||||
type RevokeAuthSessionCommand struct {
|
||||
UserID string `json:"user_id"`
|
||||
AuthSessionID string `json:"auth_session_id"`
|
||||
|
||||
@@ -34,6 +34,10 @@ func (m *Module) RegisterRoutes(router chi.Router) {
|
||||
r.Get("/devices", m.handleTrustedDevices)
|
||||
r.Post("/devices/{deviceID}/revoke", m.handleRevokeTrustedDevice)
|
||||
})
|
||||
router.Route("/users", func(r chi.Router) {
|
||||
r.Get("/", m.handleListUsers)
|
||||
r.Post("/", m.handleCreateUser)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Module) handleInstallationStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -78,6 +82,32 @@ func (m *Module) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (m *Module) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
actorUserID := r.URL.Query().Get("actor_user_id")
|
||||
users, err := m.service.ListUsers(r.Context(), actorUserID)
|
||||
if err != nil {
|
||||
status, message := m.service.MapError(err)
|
||||
httpx.WriteError(w, status, message)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"users": users})
|
||||
}
|
||||
|
||||
func (m *Module) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var cmd CreateUserCommand
|
||||
if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid user payload")
|
||||
return
|
||||
}
|
||||
user, err := m.service.CreateUser(r.Context(), cmd)
|
||||
if err != nil {
|
||||
status, message := m.service.MapError(err)
|
||||
httpx.WriteError(w, status, message)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"user": user})
|
||||
}
|
||||
|
||||
func (m *Module) handleRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
var cmd RefreshCommand
|
||||
if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil {
|
||||
|
||||
@@ -70,7 +70,7 @@ type postgresInstallationRepository struct {
|
||||
|
||||
func (r *postgresUserRepository) GetByEmail(ctx context.Context, email string) (*User, error) {
|
||||
const query = `
|
||||
SELECT id::text, email, password_hash, mfa_enabled, created_at, updated_at
|
||||
SELECT id::text, email, password_hash, mfa_enabled, platform_role, created_at, updated_at
|
||||
FROM users
|
||||
WHERE email = $1
|
||||
`
|
||||
@@ -79,13 +79,53 @@ WHERE email = $1
|
||||
|
||||
func (r *postgresUserRepository) GetByID(ctx context.Context, userID string) (*User, error) {
|
||||
const query = `
|
||||
SELECT id::text, email, password_hash, mfa_enabled, created_at, updated_at
|
||||
SELECT id::text, email, password_hash, mfa_enabled, platform_role, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = $1::uuid
|
||||
`
|
||||
return scanOptionalUser(r.db.QueryRow(ctx, query, userID))
|
||||
}
|
||||
|
||||
func (r *postgresUserRepository) List(ctx context.Context) ([]User, error) {
|
||||
const query = `
|
||||
SELECT id::text, email, password_hash, mfa_enabled, platform_role, created_at, updated_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
rows, err := r.db.Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var users []User
|
||||
for rows.Next() {
|
||||
user, err := scanOptionalUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user != nil {
|
||||
users = append(users, *user)
|
||||
}
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
func (r *postgresUserRepository) Create(ctx context.Context, user User) (*User, error) {
|
||||
const query = `
|
||||
INSERT INTO users (email, password_hash, mfa_enabled, platform_role, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id::text, email, password_hash, mfa_enabled, platform_role, created_at, updated_at
|
||||
`
|
||||
return scanOptionalUser(r.db.QueryRow(ctx, query,
|
||||
user.Email,
|
||||
user.PasswordHash,
|
||||
user.MFAEnabled,
|
||||
user.PlatformRole,
|
||||
user.CreatedAt,
|
||||
user.UpdatedAt,
|
||||
))
|
||||
}
|
||||
|
||||
func (r *postgresDeviceRepository) Upsert(ctx context.Context, params UpsertDeviceParams) (*Device, error) {
|
||||
const query = `
|
||||
INSERT INTO devices (
|
||||
@@ -348,7 +388,7 @@ ON CONFLICT (email) DO UPDATE SET
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
platform_role = EXCLUDED.platform_role,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING id::text, email, password_hash, mfa_enabled, created_at, updated_at
|
||||
RETURNING id::text, email, password_hash, mfa_enabled, platform_role, created_at, updated_at
|
||||
`, email, params.PasswordHash, params.Role, now))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert bootstrap owner: %w", err)
|
||||
@@ -461,6 +501,7 @@ func scanOptionalUser(row scanner) (*User, error) {
|
||||
&user.Email,
|
||||
&user.PasswordHash,
|
||||
&user.MFAEnabled,
|
||||
&user.PlatformRole,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
); err != nil {
|
||||
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
)
|
||||
|
||||
type UserRepository interface {
|
||||
List(ctx context.Context) ([]User, error)
|
||||
GetByEmail(ctx context.Context, email string) (*User, error)
|
||||
GetByID(ctx context.Context, userID string) (*User, error)
|
||||
Create(ctx context.Context, user User) (*User, error)
|
||||
}
|
||||
|
||||
type DeviceRepository interface {
|
||||
|
||||
@@ -13,11 +13,13 @@ import (
|
||||
|
||||
"github.com/example/remote-access-platform/backend/internal/platform/authority"
|
||||
"github.com/example/remote-access-platform/backend/internal/platform/module"
|
||||
postgresplatform "github.com/example/remote-access-platform/backend/internal/platform/postgres"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
cfg module.Config
|
||||
store Store
|
||||
db postgresplatform.DBTX
|
||||
transactor Transactor
|
||||
tokenManager *TokenManager
|
||||
authority *authority.Verifier
|
||||
@@ -31,7 +33,7 @@ func NewService(deps module.Dependencies, store Store, transactor Transactor, ve
|
||||
} else if verifier, err := authority.NewVerifier(deps.Config.Installation); err == nil {
|
||||
authorityVerifier = verifier
|
||||
}
|
||||
return &Service{
|
||||
service := &Service{
|
||||
cfg: deps.Config,
|
||||
store: store,
|
||||
transactor: transactor,
|
||||
@@ -45,6 +47,10 @@ func NewService(deps module.Dependencies, store Store, transactor Transactor, ve
|
||||
authority: authorityVerifier,
|
||||
now: time.Now,
|
||||
}
|
||||
if postgresStore, ok := store.(*postgresStore); ok {
|
||||
service.db = postgresStore.db
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, cmd LoginCommand) (*AuthResult, error) {
|
||||
@@ -120,6 +126,44 @@ func (s *Service) Login(ctx context.Context, cmd LoginCommand) (*AuthResult, err
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListUsers(ctx context.Context, actorUserID string) ([]User, error) {
|
||||
if err := s.ensurePlatformAdmin(ctx, actorUserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.Users().List(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) CreateUser(ctx context.Context, cmd CreateUserCommand) (*User, error) {
|
||||
if err := s.ensurePlatformAdmin(ctx, cmd.ActorUserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(cmd.Email))
|
||||
password := strings.TrimSpace(cmd.Password)
|
||||
role := strings.TrimSpace(cmd.PlatformRole)
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
if email == "" || !strings.Contains(email, "@") || len(password) < 8 {
|
||||
return nil, ErrInvalidBootstrapOwner
|
||||
}
|
||||
if role != "user" && role != authority.PlatformRoleAdmin && role != authority.PlatformRoleRecoveryAdmin {
|
||||
return nil, ErrInvalidBootstrapOwner
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hash user password: %w", err)
|
||||
}
|
||||
now := s.now().UTC()
|
||||
return s.store.Users().Create(ctx, User{
|
||||
Email: email,
|
||||
PasswordHash: string(passwordHash),
|
||||
MFAEnabled: false,
|
||||
PlatformRole: role,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) Refresh(ctx context.Context, cmd RefreshCommand) (*AuthResult, error) {
|
||||
authSessionID, err := s.tokenManager.ParseRefreshToken(cmd.RefreshToken)
|
||||
if err != nil {
|
||||
@@ -438,3 +482,25 @@ func (s *Service) installationStatusFromRecord(record *InstallationAuthorityStat
|
||||
func (s *Service) strictAuthority() bool {
|
||||
return s.authority != nil && s.authority.Strict()
|
||||
}
|
||||
|
||||
func (s *Service) ensurePlatformAdmin(ctx context.Context, actorUserID string) error {
|
||||
if actorUserID == "" {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
role := authority.PlatformRoleUser
|
||||
if s.db != nil {
|
||||
effectiveRole, err := authority.EffectivePlatformRole(ctx, s.db, s.authority, actorUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
role = effectiveRole
|
||||
} else if user, err := s.store.Users().GetByID(ctx, actorUserID); err != nil {
|
||||
return err
|
||||
} else if user != nil && user.PlatformRole != "" {
|
||||
role = user.PlatformRole
|
||||
}
|
||||
if role != authority.PlatformRoleAdmin && role != authority.PlatformRoleRecoveryAdmin {
|
||||
return ErrDeviceRevoked
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user