Implement user deletion

- add audit logging for user changes
- refactor model errors into functions
- implement user delete form and submit handlers
This commit is contained in:
Jan Dittberner 2022-06-01 18:57:38 +02:00
parent db52f88e25
commit 5efc57d2c3
11 changed files with 385 additions and 70 deletions

View file

@ -579,10 +579,8 @@ func (app *application) proxyVoteSubmit(w http.ResponseWriter, r *http.Request)
return
}
voter, err := app.users.ByID(r.Context(), form.Voter.ID)
if err != nil {
app.serverError(w, err)
voter := app.getVoter(w, r, form.Voter.ID)
if voter == nil {
return
}
@ -637,6 +635,7 @@ func (app *application) editUserForm(_ http.ResponseWriter, _ *http.Request) {
// TODO: implement editUserForm
panic("not implemented")
}
func (app *application) editUserSubmit(_ http.ResponseWriter, _ *http.Request) {
// TODO: implement editUserSubmit
panic("not implemented")
@ -652,14 +651,77 @@ func (app *application) userAddEmailSubmit(_ http.ResponseWriter, _ *http.Reques
panic("not implemented")
}
func (app *application) deleteUserForm(_ http.ResponseWriter, _ *http.Request) {
// TODO: implement deleteUserForm
panic("not implemented")
func (app *application) deleteUserForm(w http.ResponseWriter, r *http.Request) {
userToDelete := app.userFromRequestParam(w, r, httprouter.ParamsFromContext(r.Context()), app.users.CanDeleteOption())
if userToDelete == nil {
return
}
if !userToDelete.CanDelete() {
app.notFound(w)
return
}
data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers)
data.Form = &forms.DeleteUserForm{
User: userToDelete,
}
app.render(w, http.StatusOK, "delete_user.html", data)
}
func (app *application) deleteUserSubmit(_ http.ResponseWriter, _ *http.Request) {
// TODO: implement deleteUserSubmit
panic("not implemented")
func (app *application) deleteUserSubmit(w http.ResponseWriter, r *http.Request) {
userToDelete := app.userFromRequestParam(w, r, httprouter.ParamsFromContext(r.Context()), app.users.CanDeleteOption())
if userToDelete == nil {
return
}
if !userToDelete.CanDelete() {
app.notFound(w)
return
}
var form forms.DeleteUserForm
err := app.decodePostForm(r, &form)
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.User = userToDelete
form.Validate()
data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers)
if !form.Valid() {
data.Form = &form
app.render(w, http.StatusUnprocessableEntity, "delete_user.html", data)
return
}
form.Normalize()
if err = app.users.DeleteUser(r.Context(), userToDelete, data.User, form.Reasoning); err != nil {
app.serverError(w, err)
return
}
app.sessionManager.Put(
r.Context(),
"flash",
fmt.Sprintf("User %s has been deleted.", userToDelete.Name),
)
http.Redirect(w, r, "/users/", http.StatusSeeOther)
}
func (app *application) healthCheck(w http.ResponseWriter, _ *http.Request) {

View file

@ -28,6 +28,7 @@ import (
"net/http"
"path/filepath"
"runtime/debug"
"strconv"
"strings"
"github.com/Masterminds/sprig/v3"
@ -196,6 +197,32 @@ func (app *application) motionFromRequestParam(
return motion
}
func (app *application) userFromRequestParam(
w http.ResponseWriter, r *http.Request, params httprouter.Params, options ...models.UserListOption,
) *models.User {
userID, err := strconv.Atoi(params.ByName("id"))
if err != nil {
app.clientError(w, http.StatusBadRequest)
return nil
}
user, err := app.users.ByID(r.Context(), int64(userID), options...)
if err != nil {
app.serverError(w, err)
return nil
}
if user == nil {
app.notFound(w)
return nil
}
return user
}
func (app *application) choiceFromRequestParam(w http.ResponseWriter, params httprouter.Params) *models.VoteChoice {
choiceParam := params.ByName("choice")

View file

@ -209,6 +209,31 @@ func (app *application) startHTTPSServer(config *Config) error {
return nil
}
func (app *application) getVoter(w http.ResponseWriter, r *http.Request, voterID int64) *models.User {
voter, err := app.users.ByID(r.Context(), voterID, app.users.WithRolesOption())
if err != nil {
app.serverError(w, err)
return nil
}
var isVoter bool
if isVoter, err = voter.HasRole(models.RoleVoter); err != nil {
app.serverError(w, err)
return nil
}
if !isVoter {
app.clientError(w, http.StatusBadRequest)
return nil
}
return voter
}
func setupHTTPRedirect(config *Config, errChan chan error) {
redirect := &http.Server{
Addr: config.HTTPAddress,

View file

@ -119,3 +119,22 @@ func (f *ProxyVoteForm) Validate() {
func (f *ProxyVoteForm) Normalize() {
f.Justification = strings.TrimSpace(f.Justification)
}
type DeleteUserForm struct {
User *models.User `form:"user"`
Reasoning string `form:"reasoning"`
validator.Validator `form:"-"`
}
func (f *DeleteUserForm) Validate() {
f.CheckField(validator.NotBlank(f.Reasoning), "reasoning", "This field cannot be blank")
f.CheckField(
validator.MinChars(f.Reasoning, minimumJustificationLen),
"reasoning",
fmt.Sprintf("This field must be at least %d characters long", minimumJustificationLen),
)
}
func (f *DeleteUserForm) Normalize() {
f.Reasoning = strings.TrimSpace(f.Reasoning)
}

View file

@ -0,0 +1,3 @@
-- add an audit table to track changes to users
DROP INDEX audit_change_idx;
DROP TABLE audit;

View file

@ -0,0 +1,18 @@
-- add an audit table to track changes to users
CREATE TABLE audit
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- copied authenticated user name to keep the information when the admin is locked/deleted later
user_name VARCHAR(255) NOT NULL,
-- copied authenticated user email address to keep the information when the admin is locked/deleted later
user_address VARCHAR(255) NOT NULL,
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- an enum (in code) value to specify the action. Stored as text to be flexible for future changes
change TEXT NOT NULL,
-- reasoning for the change specified by the user
reasoning TEXT NOT NULL,
-- additional information about the change in an application specific json format
details TEXT
);
CREATE INDEX audit_change_idx ON audit (change);

67
internal/models/audit.go Normal file
View file

@ -0,0 +1,67 @@
/*
Copyright 2022 CAcert Inc.
SPDX-License-Identifier: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package models
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jmoiron/sqlx"
)
type AuditChange string
const (
AuditCreateUser AuditChange = "CREATE_USER"
AuditDeleteUser AuditChange = "DELETE_USER"
AuditEditUser AuditChange = "EDIT_USER"
AuditAddEmail AuditChange = "ADD_EMAIL"
AuditRemoveEmail AuditChange = "REMOVE_EMAIL"
AuditAddRole AuditChange = "ADD_ROLE"
AuditRemoveRole AuditChange = "REMOVE_ROLE"
)
type Audit struct {
ID int64 `db:"id"`
UserName string `db:"user_name"`
UserAddress string `db:"user_address"`
Created time.Time `db:"created"`
Change *AuditChange `db:"change"`
Reasoning string `db:"reasoning"`
Details string `db:"details"`
}
func AuditLog(ctx context.Context, tx *sqlx.Tx, user *User, change AuditChange, reasoning string, details any) error {
jsonDetails, err := json.Marshal(details)
if err != nil {
return fmt.Errorf("could not transform details to JSON: %w", err)
}
_, err = tx.ExecContext(
ctx,
`INSERT INTO audit (user_name, user_address, created, change, reasoning, details) VALUES (?, ?, ?, ?, ?, ?)`,
user.Name, user.Reminder, time.Now().UTC(), change, reasoning, string(jsonDetails),
)
if err != nil {
return errCouldNotExecuteQuery(err)
}
return nil
}

View file

@ -49,11 +49,21 @@ func parseSqlite3TimeStamp(timeStamp string) (*time.Time, error) {
return nil, fmt.Errorf("could not parse timestamp: %w", err)
}
const (
errCouldNotExecuteQuery = "could not execute query: %w"
errCouldNotFetchRow = "could not fetch row: %w"
errCouldNotScanResult = "could not scan result: %w"
errCouldNotStartTransaction = "could not start transaction: %w"
errCouldNotCommitTransaction = "could not commit transaction: %w"
errCouldNotCreateInQuery = "could not create query with IN clause: %w"
)
func errCouldNotExecuteQuery(err error) error {
return fmt.Errorf("could not execute query: %w", err)
}
func errCouldNotFetchRow(err error) error {
return fmt.Errorf("could not fetch row: %w", err)
}
func errCouldNotScanResult(err error) error {
return fmt.Errorf("could not scan result: %w", err)
}
func errCouldNotStartTransaction(err error) error {
return fmt.Errorf("could not start transaction: %w", err)
}
func errCouldNotCommitTransaction(err error) error {
return fmt.Errorf("could not commit transaction: %w", err)
}
func errCouldNotCreateInQuery(err error) error {
return fmt.Errorf("could not create query with IN clause: %w", err)
}

View file

@ -302,7 +302,7 @@ VALUES (:proposed, :proponent, :title, :content, :votetype, :status, :due, :prop
func (m *MotionModel) CloseDecisions(ctx context.Context) ([]*Motion, error) {
tx, err := m.DB.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf(errCouldNotStartTransaction, err)
return nil, errCouldNotStartTransaction(err)
}
defer func(tx *sqlx.Tx) {
@ -327,7 +327,7 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now
for rows.Next() {
decision := &Motion{}
if err = rows.StructScan(decision); err != nil {
return nil, fmt.Errorf(errCouldNotScanResult, err)
return nil, errCouldNotScanResult(err)
}
if rows.Err() != nil {
@ -350,7 +350,7 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now
}
if err = tx.Commit(); err != nil {
return nil, fmt.Errorf(errCouldNotCommitTransaction, err)
return nil, errCouldNotCommitTransaction(err)
}
return results, nil
@ -379,7 +379,7 @@ func closeDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*Motion, error)
)
if err != nil {
return nil, fmt.Errorf(errCouldNotExecuteQuery, err)
return nil, errCouldNotExecuteQuery(err)
}
affectedRows, err := result.RowsAffected()
@ -408,7 +408,7 @@ WHERE due < ? AND status=? AND NOT EXISTS(SELECT * FROM votes WHERE decision = d
voteStatusPending,
voter.ID)
if err != nil {
return nil, fmt.Errorf(errCouldNotExecuteQuery, err)
return nil, errCouldNotExecuteQuery(err)
}
defer func(rows *sqlx.Rows) {
@ -419,13 +419,13 @@ WHERE due < ? AND status=? AND NOT EXISTS(SELECT * FROM votes WHERE decision = d
for rows.Next() {
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(errCouldNotFetchRow, err)
return nil, errCouldNotFetchRow(err)
}
var motion Motion
if err := rows.StructScan(&motion); err != nil {
return nil, fmt.Errorf(errCouldNotScanResult, err)
return nil, errCouldNotScanResult(err)
}
result = append(result, &motion)
@ -590,11 +590,11 @@ func (m *MotionModel) List(ctx context.Context, options *MotionListOptions) ([]*
var decision Motion
if err = rows.Err(); err != nil {
return nil, fmt.Errorf(errCouldNotFetchRow, err)
return nil, errCouldNotFetchRow(err)
}
if err = rows.StructScan(&decision); err != nil {
return nil, fmt.Errorf(errCouldNotScanResult, err)
return nil, errCouldNotScanResult(err)
}
motions = append(motions, &decision)
@ -633,7 +633,7 @@ GROUP BY v.decision, v.vote`,
rows, err := m.DB.QueryContext(ctx, query, args...)
if err != nil {
return fmt.Errorf(errCouldNotExecuteQuery, err)
return errCouldNotExecuteQuery(err)
}
defer func(rows *sql.Rows) {
@ -642,7 +642,7 @@ GROUP BY v.decision, v.vote`,
for rows.Next() {
if err = rows.Err(); err != nil {
return fmt.Errorf(errCouldNotFetchRow, err)
return errCouldNotFetchRow(err)
}
var (
@ -653,7 +653,7 @@ GROUP BY v.decision, v.vote`,
err = rows.Scan(&decisionID, &vote, &count)
if err != nil {
return fmt.Errorf(errCouldNotScanResult, err)
return errCouldNotScanResult(err)
}
switch {
@ -861,7 +861,7 @@ ORDER BY voters.name`,
var vote Vote
if err := rows.StructScan(&vote); err != nil {
return fmt.Errorf(errCouldNotScanResult, err)
return errCouldNotScanResult(err)
}
md.Votes = append(md.Votes, &vote)
@ -893,7 +893,7 @@ func (m *MotionModel) Update(
) error {
tx, err := m.DB.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf(errCouldNotStartTransaction, err)
return errCouldNotStartTransaction(err)
}
defer func(tx *sqlx.Tx) {
@ -908,7 +908,7 @@ func (m *MotionModel) Update(
var motion Motion
if err := row.StructScan(&motion); err != nil {
return fmt.Errorf(errCouldNotScanResult, err)
return errCouldNotScanResult(err)
}
updateFn(&motion)
@ -932,7 +932,7 @@ WHERE id = :id`,
}
if err := tx.Commit(); err != nil {
return fmt.Errorf(errCouldNotCommitTransaction, err)
return errCouldNotCommitTransaction(err)
}
return nil
@ -950,7 +950,7 @@ type Vote struct {
func (m *MotionModel) UpdateVote(ctx context.Context, userID, motionID int64, performVoteFn func(v *Vote)) error {
tx, err := m.DB.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf(errCouldNotStartTransaction, err)
return errCouldNotStartTransaction(err)
}
defer func(tx *sqlx.Tx) {
@ -959,7 +959,7 @@ func (m *MotionModel) UpdateVote(ctx context.Context, userID, motionID int64, pe
row := tx.QueryRowxContext(ctx, `SELECT * FROM votes WHERE voter=? AND decision=?`, userID, motionID)
if err := row.Err(); err != nil {
return fmt.Errorf(errCouldNotExecuteQuery, err)
return errCouldNotExecuteQuery(err)
}
vote := Vote{UserID: userID, MotionID: motionID}
@ -986,7 +986,7 @@ WHERE decision = :decision
}
if err := tx.Commit(); err != nil {
return fmt.Errorf(errCouldNotCommitTransaction, err)
return errCouldNotCommitTransaction(err)
}
return nil

View file

@ -320,16 +320,16 @@ func (m *UserModel) Voters(ctx context.Context) ([]*User, error) {
return m.InRole(ctx, RoleVoter)
}
func (m *UserModel) ByID(ctx context.Context, voterID int64) (*User, error) {
func (m *UserModel) ByID(ctx context.Context, voterID int64, options ...UserListOption) (*User, error) {
row := m.DB.QueryRowxContext(
ctx,
`SELECT DISTINCT v.id, v.name
`SELECT DISTINCT v.id, v.name, e.address AS reminder
FROM voters v
JOIN user_roles ur ON v.id = ur.voter_id
LEFT OUTER JOIN emails e ON e.voter = v.id
WHERE v.id = ?
AND ur.role = ?`,
AND e.reminder = TRUE
`,
voterID,
RoleVoter,
)
if err := row.Err(); err != nil {
@ -342,6 +342,12 @@ WHERE v.id = ?
return nil, fmt.Errorf("could not scan row: %w", err)
}
for _, option := range options {
if err := option(ctx, []*User{&user}); err != nil {
return nil, err
}
}
return &user, nil
}
@ -350,7 +356,7 @@ type UserListOption func(context.Context, []*User) error
func (m *UserModel) List(ctx context.Context, options ...UserListOption) ([]*User, error) {
rows, err := m.DB.QueryxContext(ctx, `SELECT id, name FROM voters ORDER BY name`)
if err != nil {
return nil, fmt.Errorf(errCouldNotExecuteQuery, err)
return nil, errCouldNotExecuteQuery(err)
}
defer func(rows *sqlx.Rows) {
@ -361,12 +367,12 @@ func (m *UserModel) List(ctx context.Context, options ...UserListOption) ([]*Use
for rows.Next() {
if err = rows.Err(); err != nil {
return nil, fmt.Errorf(errCouldNotFetchRow, err)
return nil, errCouldNotFetchRow(err)
}
var user User
if err = rows.StructScan(&user); err != nil {
return nil, fmt.Errorf(errCouldNotScanResult, err)
return nil, errCouldNotScanResult(err)
}
users = append(users, &user)
@ -397,37 +403,21 @@ func (m *UserModel) WithRolesOption() UserListOption {
userIDs,
)
if err != nil {
return fmt.Errorf(errCouldNotCreateInQuery, err)
return errCouldNotCreateInQuery(err)
}
rows, err := m.DB.QueryxContext(ctx, query, args...)
if err != nil {
return fmt.Errorf(errCouldNotExecuteQuery, err)
return errCouldNotExecuteQuery(err)
}
defer func(rows *sqlx.Rows) {
_ = rows.Close()
}(rows)
roleMap := make(map[int64][]*Role, len(users))
for rows.Next() {
if err := rows.Err(); err != nil {
return fmt.Errorf(errCouldNotFetchRow, err)
}
var userID int64
var role Role
if err := rows.Scan(&userID, &role); err != nil {
return fmt.Errorf(errCouldNotScanResult, err)
}
if _, ok := roleMap[userID]; !ok {
roleMap[userID] = []*Role{&role}
} else {
roleMap[userID] = append(roleMap[userID], &role)
}
roleMap, err := buildRoleMap(rows, len(users))
if err != nil {
return err
}
for idx := range users {
@ -444,6 +434,33 @@ func (m *UserModel) WithRolesOption() UserListOption {
}
}
func buildRoleMap(rows *sqlx.Rows, userCount int) (map[int64][]*Role, error) {
roleMap := make(map[int64][]*Role, userCount)
for rows.Next() {
if err := rows.Err(); err != nil {
return nil, errCouldNotFetchRow(err)
}
var (
userID int64
role Role
)
if err := rows.Scan(&userID, &role); err != nil {
return nil, errCouldNotScanResult(err)
}
if _, ok := roleMap[userID]; !ok {
roleMap[userID] = []*Role{&role}
} else {
roleMap[userID] = append(roleMap[userID], &role)
}
}
return roleMap, nil
}
func (m *UserModel) CanDeleteOption() UserListOption {
return func(ctx context.Context, users []*User) error {
if len(users) == 0 {
@ -465,12 +482,12 @@ WHERE id IN (?)
userIDs,
)
if err != nil {
return fmt.Errorf(errCouldNotCreateInQuery, err)
return errCouldNotCreateInQuery(err)
}
rows, err := m.DB.QueryxContext(ctx, query, args...)
if err != nil {
return fmt.Errorf(errCouldNotExecuteQuery, err)
return errCouldNotExecuteQuery(err)
}
defer func(rows *sqlx.Rows) {
@ -479,13 +496,13 @@ WHERE id IN (?)
for rows.Next() {
if err := rows.Err(); err != nil {
return fmt.Errorf(errCouldNotFetchRow, err)
return errCouldNotFetchRow(err)
}
var userID int64
if err := rows.Scan(&userID); err != nil {
return fmt.Errorf(errCouldNotScanResult, err)
return errCouldNotScanResult(err)
}
for idx := range users {
@ -498,3 +515,40 @@ WHERE id IN (?)
return nil
}
}
func (m *UserModel) DeleteUser(ctx context.Context, userToDelete, admin *User, reasoning string) error {
userInfo := struct {
Name string `json:"user"`
Address string `json:"address"`
}{Name: userToDelete.Name, Address: userToDelete.Reminder}
details := struct {
User any `json:"user"`
}{User: userInfo}
tx, err := m.DB.BeginTxx(ctx, nil)
if err != nil {
return errCouldNotStartTransaction(err)
}
defer func(tx *sqlx.Tx) {
_ = tx.Rollback()
}(tx)
if _, err = tx.ExecContext(ctx, `DELETE FROM emails WHERE voter=?`, userToDelete.ID); err != nil {
return errCouldNotExecuteQuery(err)
}
if _, err = tx.ExecContext(ctx, `DELETE FROM voters WHERE id=?`, userToDelete.ID); err != nil {
return errCouldNotExecuteQuery(err)
}
if err = AuditLog(ctx, tx, admin, AuditDeleteUser, reasoning, details); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return errCouldNotCommitTransaction(err)
}
return nil
}

View file

@ -0,0 +1,30 @@
{{ define "title" }}Delete User {{ .Form.User.Name }}{{ end }}
{{ define "main" }}
{{ $form := .Form }}
{{ $user := .User }}
<div class="ui form segment">
<div class="ui negative message">
<div class="header">
Withdraw motion?
</div>
<p>Do you want to delete user <strong>{{ .Form.User.Name }}</strong>?</p>
</div>
<form action="/users/{{ .Form.User.ID }}/delete" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="ui form{{ if .Form.FieldErrors }} error{{ end }}">
<div class="required field{{ if .Form.FieldErrors.reasoning }} error{{ end }}">
<label for="reasoning">Reasoning for the deletion</label>
<textarea id="reasoning" name="reasoning" rows="2">{{ .Form.Reasoning }}</textarea>
{{ if .Form.FieldErrors.reasoning }}
<span class="ui small error text">{{ .Form.FieldErrors.reasoning }}</span>
{{ end }}
</div>
<button class="ui negative labeled icon button" type="submit">
<i class="trash icon"></i> Delete user
</button>
<a href="/motions/" class="ui button">Cancel</a>
</div>
</form>
</div>
{{ end }}