337 lines
10 KiB
Go
337 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"time"
|
|
|
|
"gardomatic.kleiax.de/internal/auth"
|
|
"gardomatic.kleiax.de/internal/storage"
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
var (
|
|
errUserNotFound = errors.New("user not found")
|
|
errUserAlreadyActive = errors.New("user is already active")
|
|
)
|
|
|
|
type userView struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
Activated bool `json:"activated"`
|
|
Role string `json:"role"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type gardenMemberView struct {
|
|
GardenID int `json:"garden_id"`
|
|
UserID int `json:"user_id"`
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
JoinedAt time.Time `json:"joined_at"`
|
|
}
|
|
|
|
type createUserInput struct {
|
|
Name string
|
|
Email string
|
|
PasswordHash []byte
|
|
Activated bool
|
|
Role string
|
|
Invite bool
|
|
TokenTTL time.Duration
|
|
}
|
|
|
|
type createUserResult struct {
|
|
User userView
|
|
Token *auth.Token
|
|
}
|
|
|
|
type adminStore interface {
|
|
Ping(context.Context) error
|
|
CreateUser(context.Context, createUserInput) (createUserResult, error)
|
|
ListUsers(context.Context) ([]userView, error)
|
|
GetUser(context.Context, string) (userView, error)
|
|
SetActivated(context.Context, string, bool) (userView, error)
|
|
IssueInvitation(context.Context, string, time.Duration) (userView, auth.Token, error)
|
|
ResetPassword(context.Context, string, []byte) (userView, error)
|
|
SetUserRole(context.Context, string, string) (userView, error)
|
|
AddGardenMember(context.Context, int, string, string) (gardenMemberView, error)
|
|
Close() error
|
|
}
|
|
|
|
type postgresStore struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func openPostgresStore(dsn string) (adminStore, error) {
|
|
if dsn == "" {
|
|
return nil, errors.New("database DSN is missing; set GARDOMATIC_DB_DSN or use --dsn")
|
|
}
|
|
db, err := sql.Open("postgres", dsn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db.SetMaxOpenConns(5)
|
|
db.SetMaxIdleConns(2)
|
|
db.SetConnMaxIdleTime(5 * time.Minute)
|
|
return &postgresStore{db: db}, nil
|
|
}
|
|
|
|
// Close releases the CLI database pool.
|
|
func (s *postgresStore) Close() error { return s.db.Close() }
|
|
|
|
// Ping verifies that the CLI can reach PostgreSQL within a bounded timeout.
|
|
func (s *postgresStore) Ping(ctx context.Context) error {
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
return s.db.PingContext(ctx)
|
|
}
|
|
|
|
// CreateUser creates an account and optionally an invitation token atomically.
|
|
func (s *postgresStore) CreateUser(ctx context.Context, input createUserInput) (createUserResult, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return createUserResult{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
var result createUserResult
|
|
err = tx.QueryRowContext(ctx, `
|
|
INSERT INTO users (name, email, password_hash, activated, application_role)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id, name, email, activated, application_role, created_at, updated_at`,
|
|
input.Name, input.Email, input.PasswordHash, input.Activated, input.Role,
|
|
).Scan(
|
|
&result.User.ID,
|
|
&result.User.Name,
|
|
&result.User.Email,
|
|
&result.User.Activated,
|
|
&result.User.Role,
|
|
&result.User.CreatedAt,
|
|
&result.User.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return createUserResult{}, mapPostgresError(err)
|
|
}
|
|
|
|
if input.Invite {
|
|
token := auth.NewToken(result.User.ID, input.TokenTTL, auth.ScopeActivation)
|
|
if _, err = tx.ExecContext(ctx, `
|
|
INSERT INTO tokens (hash, user_id, expiry, scope)
|
|
VALUES ($1, $2, $3, $4)`, token.Hash, token.UserID, token.Expiry, token.Scope); err != nil {
|
|
return createUserResult{}, err
|
|
}
|
|
result.Token = &token
|
|
}
|
|
|
|
if err = tx.Commit(); err != nil {
|
|
return createUserResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ListUsers returns all non-deleted accounts for CLI administration.
|
|
func (s *postgresStore) ListUsers(ctx context.Context) ([]userView, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT id, name, email, activated, application_role, created_at, updated_at
|
|
FROM users
|
|
WHERE deleted_at IS NULL
|
|
ORDER BY id`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
users := make([]userView, 0)
|
|
for rows.Next() {
|
|
var user userView
|
|
if err = rows.Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
users = append(users, user)
|
|
}
|
|
return users, rows.Err()
|
|
}
|
|
|
|
// GetUser returns a non-deleted account by email.
|
|
func (s *postgresStore) GetUser(ctx context.Context, email string) (userView, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
return getUser(ctx, s.db, email)
|
|
}
|
|
|
|
// SetActivated changes whether an account may authenticate.
|
|
func (s *postgresStore) SetActivated(ctx context.Context, email string, activated bool) (userView, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return userView{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
var user userView
|
|
err = tx.QueryRowContext(ctx, `
|
|
UPDATE users
|
|
SET activated = $2, updated_at = CURRENT_TIMESTAMP, version = version + 1
|
|
WHERE email = $1 AND deleted_at IS NULL
|
|
RETURNING id, name, email, activated, application_role, created_at, updated_at`, email, activated,
|
|
).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return userView{}, errUserNotFound
|
|
}
|
|
if err != nil {
|
|
return userView{}, err
|
|
}
|
|
if activated {
|
|
if _, err = tx.ExecContext(ctx, `DELETE FROM tokens WHERE user_id = $1 AND scope = $2`, user.ID, auth.ScopeActivation); err != nil {
|
|
return userView{}, err
|
|
}
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
return userView{}, err
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
// IssueInvitation replaces an account's activation token.
|
|
func (s *postgresStore) IssueInvitation(ctx context.Context, email string, ttl time.Duration) (userView, auth.Token, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return userView{}, auth.Token{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
user, err := getUser(ctx, tx, email)
|
|
if err != nil {
|
|
return userView{}, auth.Token{}, err
|
|
}
|
|
if user.Activated {
|
|
return userView{}, auth.Token{}, errUserAlreadyActive
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `DELETE FROM tokens WHERE user_id = $1 AND scope = $2`, user.ID, auth.ScopeActivation); err != nil {
|
|
return userView{}, auth.Token{}, err
|
|
}
|
|
token := auth.NewToken(user.ID, ttl, auth.ScopeActivation)
|
|
if _, err = tx.ExecContext(ctx, `
|
|
INSERT INTO tokens (hash, user_id, expiry, scope)
|
|
VALUES ($1, $2, $3, $4)`, token.Hash, token.UserID, token.Expiry, token.Scope); err != nil {
|
|
return userView{}, auth.Token{}, err
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
return userView{}, auth.Token{}, err
|
|
}
|
|
return user, token, nil
|
|
}
|
|
|
|
// ResetPassword replaces an account password hash and revokes authentication tokens.
|
|
func (s *postgresStore) ResetPassword(ctx context.Context, email string, passwordHash []byte) (userView, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return userView{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
var user userView
|
|
err = tx.QueryRowContext(ctx, `
|
|
UPDATE users
|
|
SET password_hash = $2, updated_at = CURRENT_TIMESTAMP, version = version + 1
|
|
WHERE email = $1 AND deleted_at IS NULL
|
|
RETURNING id, name, email, activated, application_role, created_at, updated_at`, email, passwordHash,
|
|
).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return userView{}, errUserNotFound
|
|
}
|
|
if err != nil {
|
|
return userView{}, err
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `
|
|
DELETE FROM tokens
|
|
WHERE user_id = $1 AND scope = ANY($2)`, user.ID, pq.Array([]string{auth.ScopeAuthentication, auth.ScopePasswordReset})); err != nil {
|
|
return userView{}, err
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
return userView{}, err
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
// SetUserRole changes an account's application role.
|
|
func (s *postgresStore) SetUserRole(ctx context.Context, email, role string) (userView, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
var user userView
|
|
err := s.db.QueryRowContext(ctx, `
|
|
UPDATE users SET application_role=$2, updated_at=CURRENT_TIMESTAMP, version=version+1
|
|
WHERE email=$1 AND deleted_at IS NULL
|
|
RETURNING id,name,email,activated,application_role,created_at,updated_at`, email, role,
|
|
).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return userView{}, errUserNotFound
|
|
}
|
|
return user, err
|
|
}
|
|
|
|
// AddGardenMember adds or updates a user's membership in a garden.
|
|
func (s *postgresStore) AddGardenMember(ctx context.Context, gardenID int, email, role string) (gardenMemberView, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
var member gardenMemberView
|
|
err := s.db.QueryRowContext(ctx, `
|
|
INSERT INTO garden_members(garden_id,user_id,role)
|
|
SELECT $1,u.id,$3 FROM users u
|
|
WHERE u.email=$2 AND u.deleted_at IS NULL
|
|
AND EXISTS (SELECT 1 FROM gardens WHERE id=$1)
|
|
AND EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1))
|
|
RETURNING garden_id,user_id,$2,role,joined_at`, gardenID, email, role,
|
|
).Scan(&member.GardenID, &member.UserID, &member.Email, &member.Role, &member.JoinedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return gardenMemberView{}, storage.ErrRecordNotFound
|
|
}
|
|
return member, mapPostgresError(err)
|
|
}
|
|
|
|
type dbQuerier interface {
|
|
QueryRowContext(context.Context, string, ...any) *sql.Row
|
|
}
|
|
|
|
func getUser(ctx context.Context, db dbQuerier, email string) (userView, error) {
|
|
var user userView
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT id, name, email, activated, application_role, created_at, updated_at
|
|
FROM users
|
|
WHERE email = $1 AND deleted_at IS NULL`, email,
|
|
).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return userView{}, errUserNotFound
|
|
}
|
|
if err != nil {
|
|
return userView{}, err
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func mapPostgresError(err error) error {
|
|
var pqErr *pq.Error
|
|
if errors.As(err, &pqErr) && pqErr.Code == "23505" && pqErr.Constraint == "users_email_key" {
|
|
return storage.ErrDuplicateEmail
|
|
}
|
|
return err
|
|
}
|