diff --git a/cmd/api/integration_test.go b/cmd/api/integration_test.go index 16a632c..ac42355 100644 --- a/cmd/api/integration_test.go +++ b/cmd/api/integration_test.go @@ -38,6 +38,7 @@ func (s *ts) do(method, path string, body any) *http.Response { if err != nil { s.Fatalf("new request: %v", err) } + req.Header.Set("X-Owner-ID", "1") if body != nil { req.Header.Set("Content-Type", "application/json") } diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..9e4729f --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,41 @@ +package auth + +import ( + "context" + "fmt" + "net/http" + "strconv" +) + +type contextKey struct{} + +func WithOwner(ctx context.Context, ownerID int32) context.Context { + return context.WithValue(ctx, contextKey{}, ownerID) +} + +func OwnerFromContext(ctx context.Context) (int32, bool) { + id, ok := ctx.Value(contextKey{}).(int32) + return id, ok +} + +// Middleware extrait X-Owner-ID du header et l'injecte dans le contexte. +// Pour le dev, c'est l'ID numérique de l'owner. À remplacer par JWT en prod. +func Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + v := r.Header.Get("X-Owner-ID") + if v == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintln(w, `{"error":"missing X-Owner-ID header"}`) + return + } + id, err := strconv.Atoi(v) + if err != nil || id <= 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintln(w, `{"error":"invalid X-Owner-ID"}`) + return + } + next.ServeHTTP(w, r.WithContext(WithOwner(r.Context(), int32(id)))) + }) +} diff --git a/internal/handler/account.go b/internal/handler/account.go index 20eb0c5..873c96c 100644 --- a/internal/handler/account.go +++ b/internal/handler/account.go @@ -35,7 +35,11 @@ func (h *AccountHandler) RegisterRoutes(mux *http.ServeMux) { // --- Comptes maîtres --- func (h *AccountHandler) list(w http.ResponseWriter, r *http.Request) { - accounts, err := h.store.ListAccounts(r.Context()) + ownerID, ok := requireOwner(w, r) + if !ok { + return + } + accounts, err := h.store.ListAccounts(r.Context(), ownerID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list accounts") return @@ -44,11 +48,22 @@ func (h *AccountHandler) list(w http.ResponseWriter, r *http.Request) { } func (h *AccountHandler) get(w http.ResponseWriter, r *http.Request) { + ownerID, ok := requireOwner(w, r) + if !ok { + return + } id, err := parseID(r) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } + if err := h.store.VerifyAccountOwner(r.Context(), id, ownerID); errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "account not found") + return + } else if err != nil { + writeError(w, http.StatusInternalServerError, "failed to get account") + return + } account, err := h.store.GetAccountWithEnvelopes(r.Context(), id) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "account not found") @@ -62,6 +77,10 @@ func (h *AccountHandler) get(w http.ResponseWriter, r *http.Request) { } func (h *AccountHandler) create(w http.ResponseWriter, r *http.Request) { + ownerID, ok := requireOwner(w, r) + if !ok { + return + } var p store.CreateAccountParams if err := json.NewDecoder(r.Body).Decode(&p); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") @@ -71,7 +90,7 @@ func (h *AccountHandler) create(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "nom and type are required") return } - account, err := h.store.CreateAccount(r.Context(), p) + account, err := h.store.CreateAccount(r.Context(), p, ownerID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create account") return @@ -80,6 +99,10 @@ func (h *AccountHandler) create(w http.ResponseWriter, r *http.Request) { } func (h *AccountHandler) update(w http.ResponseWriter, r *http.Request) { + ownerID, ok := requireOwner(w, r) + if !ok { + return + } id, err := parseID(r) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") @@ -90,7 +113,7 @@ func (h *AccountHandler) update(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid request body") return } - account, err := h.store.UpdateAccount(r.Context(), id, p) + account, err := h.store.UpdateAccount(r.Context(), id, p, ownerID) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "account not found") return @@ -103,12 +126,16 @@ func (h *AccountHandler) update(w http.ResponseWriter, r *http.Request) { } func (h *AccountHandler) delete(w http.ResponseWriter, r *http.Request) { + ownerID, ok := requireOwner(w, r) + if !ok { + return + } id, err := parseID(r) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } - if err := h.store.DeleteAccount(r.Context(), id); err != nil { + if err := h.store.DeleteAccount(r.Context(), id, ownerID); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete account") return } @@ -118,11 +145,22 @@ func (h *AccountHandler) delete(w http.ResponseWriter, r *http.Request) { // --- Enveloppes --- func (h *AccountHandler) listEnvelopes(w http.ResponseWriter, r *http.Request) { + ownerID, ok := requireOwner(w, r) + if !ok { + return + } id, err := parseID(r) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } + if err := h.store.VerifyAccountOwner(r.Context(), id, ownerID); errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "account not found") + return + } else if err != nil { + writeError(w, http.StatusInternalServerError, "failed to verify account") + return + } envelopes, err := h.store.ListEnvelopes(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list envelopes") @@ -132,6 +170,10 @@ func (h *AccountHandler) listEnvelopes(w http.ResponseWriter, r *http.Request) { } func (h *AccountHandler) createEnvelope(w http.ResponseWriter, r *http.Request) { + ownerID, ok := requireOwner(w, r) + if !ok { + return + } masterID, err := parseID(r) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") @@ -146,7 +188,7 @@ func (h *AccountHandler) createEnvelope(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusBadRequest, "nom is required") return } - envelope, err := h.store.CreateEnvelope(r.Context(), masterID, p) + envelope, err := h.store.CreateEnvelope(r.Context(), masterID, p, ownerID) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "account not found") return @@ -163,6 +205,10 @@ func (h *AccountHandler) createEnvelope(w http.ResponseWriter, r *http.Request) } func (h *AccountHandler) updateEnvelope(w http.ResponseWriter, r *http.Request) { + ownerID, ok := requireOwner(w, r) + if !ok { + return + } id, err := parseID(r) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") @@ -173,7 +219,7 @@ func (h *AccountHandler) updateEnvelope(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusBadRequest, "invalid request body") return } - envelope, err := h.store.UpdateEnvelope(r.Context(), id, p) + envelope, err := h.store.UpdateEnvelope(r.Context(), id, p, ownerID) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "envelope not found") return @@ -186,12 +232,16 @@ func (h *AccountHandler) updateEnvelope(w http.ResponseWriter, r *http.Request) } func (h *AccountHandler) deleteEnvelope(w http.ResponseWriter, r *http.Request) { + ownerID, ok := requireOwner(w, r) + if !ok { + return + } id, err := parseID(r) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } - if err := h.store.DeleteAccount(r.Context(), id); err != nil { + if err := h.store.DeleteAccount(r.Context(), id, ownerID); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete envelope") return } diff --git a/internal/handler/response.go b/internal/handler/response.go index 43a60fc..e17ae33 100644 --- a/internal/handler/response.go +++ b/internal/handler/response.go @@ -3,6 +3,8 @@ package handler import ( "encoding/json" "net/http" + + "git.g3e.fr/H6N/account/internal/auth" ) func writeJSON(w http.ResponseWriter, status int, v any) { @@ -14,3 +16,13 @@ func writeJSON(w http.ResponseWriter, status int, v any) { func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } + +// requireOwner extrait l'owner du contexte ou écrit 401 et retourne false. +func requireOwner(w http.ResponseWriter, r *http.Request) (int32, bool) { + id, ok := auth.OwnerFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "missing owner") + return 0, false + } + return id, true +} diff --git a/internal/handler/snapshot.go b/internal/handler/snapshot.go index 3332e2e..7d51ab2 100644 --- a/internal/handler/snapshot.go +++ b/internal/handler/snapshot.go @@ -10,6 +10,26 @@ import ( "github.com/jackc/pgx/v5" ) +func (h *SnapshotHandler) verifyAccountOwner(w http.ResponseWriter, r *http.Request) (int32, int32, bool) { + ownerID, ok := requireOwner(w, r) + if !ok { + return 0, 0, false + } + accountID, err := parseID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid account id") + return 0, 0, false + } + if err := h.engine.Store().VerifyAccountOwner(r.Context(), accountID, ownerID); errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "account not found") + return 0, 0, false + } else if err != nil { + writeError(w, http.StatusInternalServerError, "failed to verify account") + return 0, 0, false + } + return ownerID, accountID, true +} + type SnapshotHandler struct { engine *snapshot.Engine } @@ -37,9 +57,8 @@ func parseDateParam(r *http.Request, key string) (time.Time, error) { // Liste les snapshots agrégés (valeur totale) d'un compte. // from/to optionnels : sans borne → pas de filtre de ce côté. func (h *SnapshotHandler) listAccountSnapshots(w http.ResponseWriter, r *http.Request) { - accountID, err := parseID(r) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid account id") + _, accountID, ok := h.verifyAccountOwner(w, r) + if !ok { return } from, err := parseDateParam(r, "from") @@ -67,9 +86,8 @@ func (h *SnapshotHandler) listAccountSnapshots(w http.ResponseWriter, r *http.Re // GET /accounts/{id}/snapshots/positions?from=YYYY-MM-DD&to=YYYY-MM-DD // Liste les positions détaillées (par instrument) d'un compte. func (h *SnapshotHandler) listPositionSnapshots(w http.ResponseWriter, r *http.Request) { - accountID, err := parseID(r) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid account id") + _, accountID, ok := h.verifyAccountOwner(w, r) + if !ok { return } from, err := parseDateParam(r, "from") @@ -98,9 +116,8 @@ func (h *SnapshotHandler) listPositionSnapshots(w http.ResponseWriter, r *http.R // Recalcule les snapshots du compte depuis sa date d'invalidation jusqu'à aujourd'hui. // Retourne 204 si aucune invalidation n'est en attente. func (h *SnapshotHandler) recomputeAccount(w http.ResponseWriter, r *http.Request) { - accountID, err := parseID(r) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid account id") + _, accountID, ok := h.verifyAccountOwner(w, r) + if !ok { return } diff --git a/internal/handler/transaction.go b/internal/handler/transaction.go index 75c115a..419bff3 100644 --- a/internal/handler/transaction.go +++ b/internal/handler/transaction.go @@ -28,11 +28,22 @@ func (h *TransactionHandler) RegisterRoutes(mux *http.ServeMux) { } func (h *TransactionHandler) listForAccount(w http.ResponseWriter, r *http.Request) { + ownerID, ok := requireOwner(w, r) + if !ok { + return + } accountID, err := parseID(r) if err != nil { writeError(w, http.StatusBadRequest, "invalid account id") return } + if err := h.store.VerifyAccountOwner(r.Context(), accountID, ownerID); errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "account not found") + return + } else if err != nil { + writeError(w, http.StatusInternalServerError, "failed to verify account") + return + } f := store.AccountTransactionFilters{ Validated: parseBoolParam(r, "validated"), Pending: parseBoolParam(r, "pending"), @@ -48,12 +59,16 @@ func (h *TransactionHandler) listForAccount(w http.ResponseWriter, r *http.Reque } func (h *TransactionHandler) get(w http.ResponseWriter, r *http.Request) { + ownerID, ok := requireOwner(w, r) + if !ok { + return + } id, err := parseTxID(r) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } - tx, err := h.store.GetTransaction(r.Context(), id) + tx, err := h.store.GetTransaction(r.Context(), id, ownerID) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "transaction not found") return @@ -66,6 +81,10 @@ func (h *TransactionHandler) get(w http.ResponseWriter, r *http.Request) { } func (h *TransactionHandler) create(w http.ResponseWriter, r *http.Request) { + ownerID, ok := requireOwner(w, r) + if !ok { + return + } var p store.CreateTransactionParams if err := json.NewDecoder(r.Body).Decode(&p); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") @@ -79,6 +98,25 @@ func (h *TransactionHandler) create(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "date is required") return } + // Vérifier que les comptes référencés appartiennent à l'owner. + if p.AccountSourceID != nil { + if err := h.store.VerifyAccountOwner(r.Context(), *p.AccountSourceID, ownerID); errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "source account not found") + return + } else if err != nil { + writeError(w, http.StatusInternalServerError, "failed to verify source account") + return + } + } + if p.AccountDestID != nil { + if err := h.store.VerifyAccountOwner(r.Context(), *p.AccountDestID, ownerID); errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "dest account not found") + return + } else if err != nil { + writeError(w, http.StatusInternalServerError, "failed to verify dest account") + return + } + } tx, err := h.store.CreateTransaction(r.Context(), p) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create transaction") @@ -88,6 +126,10 @@ func (h *TransactionHandler) create(w http.ResponseWriter, r *http.Request) { } func (h *TransactionHandler) update(w http.ResponseWriter, r *http.Request) { + ownerID, ok := requireOwner(w, r) + if !ok { + return + } id, err := parseTxID(r) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") @@ -102,7 +144,7 @@ func (h *TransactionHandler) update(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "label is required") return } - tx, err := h.store.UpdateTransaction(r.Context(), id, p) + tx, err := h.store.UpdateTransaction(r.Context(), id, p, ownerID) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "transaction not found") return @@ -115,12 +157,19 @@ func (h *TransactionHandler) update(w http.ResponseWriter, r *http.Request) { } func (h *TransactionHandler) delete(w http.ResponseWriter, r *http.Request) { + ownerID, ok := requireOwner(w, r) + if !ok { + return + } id, err := parseTxID(r) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } - if err := h.store.DeleteTransaction(r.Context(), id); err != nil { + if err := h.store.DeleteTransaction(r.Context(), id, ownerID); errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "transaction not found") + return + } else if err != nil { writeError(w, http.StatusInternalServerError, "failed to delete transaction") return } @@ -128,6 +177,10 @@ func (h *TransactionHandler) delete(w http.ResponseWriter, r *http.Request) { } func (h *TransactionHandler) validate(w http.ResponseWriter, r *http.Request) { + ownerID, ok := requireOwner(w, r) + if !ok { + return + } id, err := parseTxID(r) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") @@ -140,7 +193,7 @@ func (h *TransactionHandler) validate(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid request body") return } - tx, err := h.store.SetValidated(r.Context(), id, body.Validated) + tx, err := h.store.SetValidated(r.Context(), id, body.Validated, ownerID) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "transaction not found") return diff --git a/internal/server/server.go b/internal/server/server.go index d6d5ac6..15ddec6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -5,6 +5,7 @@ import ( "log/slog" "net/http" + "git.g3e.fr/H6N/account/internal/auth" "git.g3e.fr/H6N/account/internal/config" "git.g3e.fr/H6N/account/internal/handler" "git.g3e.fr/H6N/account/internal/snapshot" @@ -31,7 +32,12 @@ func New(cfg *config.Config, pool *pgxpool.Pool, logger *slog.Logger) *Server { } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - s.mux.ServeHTTP(w, r) + // /health est exempt d'auth. Toutes les autres routes passent par le middleware owner. + if r.URL.Path == "/health" { + s.mux.ServeHTTP(w, r) + return + } + auth.Middleware(s.mux).ServeHTTP(w, r) } func (s *Server) routes() { diff --git a/internal/snapshot/engine.go b/internal/snapshot/engine.go index 34866d8..1059df0 100644 --- a/internal/snapshot/engine.go +++ b/internal/snapshot/engine.go @@ -167,7 +167,7 @@ func (e *Engine) recomputeAccount(ctx context.Context, accountID int32, date tim var totalValeur float64 for _, pos := range positions { - if pos.Quantite <= 0 { + if pos.Quantite == 0 { continue } diff --git a/internal/store/account.go b/internal/store/account.go index a00aaed..17108fd 100644 --- a/internal/store/account.go +++ b/internal/store/account.go @@ -8,13 +8,14 @@ import ( ) type Account struct { - ID int32 `json:"id"` - Nom string `json:"nom"` - Type string `json:"type"` - DeviseReference string `json:"devise_reference"` + ID int32 `json:"id"` + Nom string `json:"nom"` + Type string `json:"type"` + DeviseReference string `json:"devise_reference"` Plafond *float64 `json:"plafond,omitempty"` - MasterAccountID *int32 `json:"master_account_id,omitempty"` - Objectif *string `json:"objectif,omitempty"` + MasterAccountID *int32 `json:"master_account_id,omitempty"` + Objectif *string `json:"objectif,omitempty"` + OwnerID int32 `json:"-"` } type CreateAccountParams struct { @@ -31,11 +32,27 @@ type CreateEnvelopeParams struct { var ErrMasterIsSubAccount = errors.New("master account cannot itself be a sub-account") -const accountCols = `id, nom, type, devise_reference, plafond, master_account_id, objectif` +const accountCols = `id, nom, type, devise_reference, plafond, master_account_id, objectif, owner_id` -func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) { +// VerifyAccountOwner retourne pgx.ErrNoRows si le compte n'appartient pas à ownerID. +func (s *Store) VerifyAccountOwner(ctx context.Context, accountID, ownerID int32) error { + var exists bool + err := s.pool.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM account WHERE id = $1 AND owner_id = $2)`, + accountID, ownerID).Scan(&exists) + if err != nil { + return err + } + if !exists { + return pgx.ErrNoRows + } + return nil +} + +func (s *Store) ListAccounts(ctx context.Context, ownerID int32) ([]Account, error) { rows, err := s.pool.Query(ctx, - `SELECT `+accountCols+` FROM account WHERE master_account_id IS NULL ORDER BY nom`) + `SELECT `+accountCols+` FROM account WHERE master_account_id IS NULL AND owner_id = $1 ORDER BY nom`, + ownerID) if err != nil { return nil, err } @@ -80,22 +97,22 @@ func (s *Store) GetAccountWithEnvelopes(ctx context.Context, id int32) (AccountW return AccountWithEnvelopes{Account: account, Envelopes: envelopes}, nil } -func (s *Store) CreateAccount(ctx context.Context, p CreateAccountParams) (Account, error) { +func (s *Store) CreateAccount(ctx context.Context, p CreateAccountParams, ownerID int32) (Account, error) { if p.DeviseReference == "" { p.DeviseReference = "EUR" } rows, err := s.pool.Query(ctx, - `INSERT INTO account (nom, type, devise_reference, plafond) - VALUES ($1, $2, $3, $4) + `INSERT INTO account (nom, type, devise_reference, plafond, owner_id) + VALUES ($1, $2, $3, $4, $5) RETURNING `+accountCols, - p.Nom, p.Type, p.DeviseReference, p.Plafond) + p.Nom, p.Type, p.DeviseReference, p.Plafond, ownerID) if err != nil { return Account{}, err } return pgx.CollectOneRow(rows, pgx.RowToStructByName[Account]) } -func (s *Store) CreateEnvelope(ctx context.Context, masterID int32, p CreateEnvelopeParams) (Account, error) { +func (s *Store) CreateEnvelope(ctx context.Context, masterID int32, p CreateEnvelopeParams, ownerID int32) (Account, error) { master, err := s.GetAccount(ctx, masterID) if err != nil { return Account{}, err @@ -103,45 +120,48 @@ func (s *Store) CreateEnvelope(ctx context.Context, masterID int32, p CreateEnve if master.MasterAccountID != nil { return Account{}, ErrMasterIsSubAccount } + if master.OwnerID != ownerID { + return Account{}, pgx.ErrNoRows + } rows, err := s.pool.Query(ctx, - `INSERT INTO account (nom, type, devise_reference, master_account_id, objectif) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO account (nom, type, devise_reference, master_account_id, objectif, owner_id) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING `+accountCols, - p.Nom, master.Type, master.DeviseReference, masterID, p.Objectif) + p.Nom, master.Type, master.DeviseReference, masterID, p.Objectif, ownerID) if err != nil { return Account{}, err } return pgx.CollectOneRow(rows, pgx.RowToStructByName[Account]) } -func (s *Store) UpdateAccount(ctx context.Context, id int32, p CreateAccountParams) (Account, error) { +func (s *Store) UpdateAccount(ctx context.Context, id int32, p CreateAccountParams, ownerID int32) (Account, error) { if p.DeviseReference == "" { p.DeviseReference = "EUR" } rows, err := s.pool.Query(ctx, `UPDATE account SET nom = $2, type = $3, devise_reference = $4, plafond = $5 - WHERE id = $1 AND master_account_id IS NULL + WHERE id = $1 AND master_account_id IS NULL AND owner_id = $6 RETURNING `+accountCols, - id, p.Nom, p.Type, p.DeviseReference, p.Plafond) + id, p.Nom, p.Type, p.DeviseReference, p.Plafond, ownerID) if err != nil { return Account{}, err } return pgx.CollectOneRow(rows, pgx.RowToStructByName[Account]) } -func (s *Store) UpdateEnvelope(ctx context.Context, id int32, p CreateEnvelopeParams) (Account, error) { +func (s *Store) UpdateEnvelope(ctx context.Context, id int32, p CreateEnvelopeParams, ownerID int32) (Account, error) { rows, err := s.pool.Query(ctx, `UPDATE account SET nom = $2, objectif = $3 - WHERE id = $1 AND master_account_id IS NOT NULL + WHERE id = $1 AND master_account_id IS NOT NULL AND owner_id = $4 RETURNING `+accountCols, - id, p.Nom, p.Objectif) + id, p.Nom, p.Objectif, ownerID) if err != nil { return Account{}, err } return pgx.CollectOneRow(rows, pgx.RowToStructByName[Account]) } -func (s *Store) DeleteAccount(ctx context.Context, id int32) error { - _, err := s.pool.Exec(ctx, `DELETE FROM account WHERE id = $1`, id) +func (s *Store) DeleteAccount(ctx context.Context, id int32, ownerID int32) error { + _, err := s.pool.Exec(ctx, `DELETE FROM account WHERE id = $1 AND owner_id = $2`, id, ownerID) return err } diff --git a/internal/store/transaction.go b/internal/store/transaction.go index f5da808..14ecd60 100644 --- a/internal/store/transaction.go +++ b/internal/store/transaction.go @@ -106,9 +106,22 @@ func (s *Store) ListAccountTransactions(ctx context.Context, accountID int32, f return pgx.CollectRows(rows, pgx.RowToStructByName[AccountTransaction]) } -func (s *Store) GetTransaction(ctx context.Context, id int64) (Transaction, error) { - rows, err := s.pool.Query(ctx, - `SELECT `+txCols+` FROM transaction WHERE id = $1`, id) +// GetTransaction retourne une transaction. Si ownerID > 0, vérifie que l'un des +// comptes impliqués appartient à cet owner (retourne ErrNoRows sinon). +func (s *Store) GetTransaction(ctx context.Context, id int64, ownerID int32) (Transaction, error) { + q := `SELECT t.id, t.date::text, t.account_source_id, t.instrument_source_id, t.quantite_source, + t.account_dest_id, t.instrument_dest_id, t.quantite_dest, + t.tiers, t.label, t.categorie, t.validated, t.recurring_rule_id + FROM transaction t + LEFT JOIN account src ON src.id = t.account_source_id + LEFT JOIN account dst ON dst.id = t.account_dest_id + WHERE t.id = $1` + args := []any{id} + if ownerID > 0 { + q += ` AND (src.owner_id = $2 OR dst.owner_id = $2)` + args = append(args, ownerID) + } + rows, err := s.pool.Query(ctx, q, args...) if err != nil { return Transaction{}, err } @@ -138,9 +151,9 @@ func (s *Store) CreateTransaction(ctx context.Context, p CreateTransactionParams return tx, nil } -func (s *Store) UpdateTransaction(ctx context.Context, id int64, p CreateTransactionParams) (Transaction, error) { +func (s *Store) UpdateTransaction(ctx context.Context, id int64, p CreateTransactionParams, ownerID int32) (Transaction, error) { // Récupérer l'ancienne date pour invalider à partir du MIN(ancienne, nouvelle). - old, err := s.GetTransaction(ctx, id) + old, err := s.GetTransaction(ctx, id, ownerID) if err != nil { return Transaction{}, err } @@ -172,7 +185,11 @@ func (s *Store) UpdateTransaction(ctx context.Context, id int64, p CreateTransac return tx, nil } -func (s *Store) SetValidated(ctx context.Context, id int64, validated bool) (Transaction, error) { +func (s *Store) SetValidated(ctx context.Context, id int64, validated bool, ownerID int32) (Transaction, error) { + // Vérifie l'ownership avant modification. + if _, err := s.GetTransaction(ctx, id, ownerID); err != nil { + return Transaction{}, err + } rows, err := s.pool.Query(ctx, `UPDATE transaction SET validated = $2 WHERE id = $1 RETURNING `+txCols, id, validated) @@ -187,8 +204,8 @@ func (s *Store) SetValidated(ctx context.Context, id int64, validated bool) (Tra return tx, nil } -func (s *Store) DeleteTransaction(ctx context.Context, id int64) error { - tx, err := s.GetTransaction(ctx, id) +func (s *Store) DeleteTransaction(ctx context.Context, id int64, ownerID int32) error { + tx, err := s.GetTransaction(ctx, id, ownerID) if err != nil { return err } diff --git a/migrations/000003_add_owner.down.sql b/migrations/000003_add_owner.down.sql new file mode 100644 index 0000000..a813528 --- /dev/null +++ b/migrations/000003_add_owner.down.sql @@ -0,0 +1 @@ +ALTER TABLE account DROP COLUMN owner_id; diff --git a/migrations/000003_add_owner.up.sql b/migrations/000003_add_owner.up.sql new file mode 100644 index 0000000..c324f59 --- /dev/null +++ b/migrations/000003_add_owner.up.sql @@ -0,0 +1 @@ +ALTER TABLE account ADD COLUMN owner_id INTEGER NOT NULL DEFAULT 1;