@@ -0,0 +1,222 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// GardenMemberModel stores garden memberships in PostgreSQL.
|
||||
// GardenMemberModel implements storage.GardenMemberModelInterface for PostgreSQL.
|
||||
type GardenMemberModel struct{ DB *sql.DB }
|
||||
|
||||
func (m GardenMemberModel) loadPermissions(member *storage.GardenMember) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `
|
||||
SELECT permission, true AS granted FROM role_permissions WHERE role_name=$1
|
||||
UNION ALL
|
||||
SELECT permission, granted FROM garden_role_permission_overrides WHERE garden_id=$2 AND role_name=$1
|
||||
ORDER BY granted DESC`, member.Role, member.GardenID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
base := []string{}
|
||||
overrides := []storage.GardenRolePermissionOverride{}
|
||||
for rows.Next() {
|
||||
var permission storage.GardenPermission
|
||||
var granted bool
|
||||
if err := rows.Scan(&permission, &granted); err != nil {
|
||||
return err
|
||||
}
|
||||
if granted {
|
||||
base = append(base, string(permission))
|
||||
} else {
|
||||
overrides = append(overrides, storage.GardenRolePermissionOverride{GardenID: member.GardenID, RoleName: string(member.Role), Permission: string(permission), Granted: false})
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
member.Permissions = storage.ResolveGardenPermissions(base, overrides)
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanGardenMember(s scanner) (storage.GardenMember, error) {
|
||||
var member storage.GardenMember
|
||||
err := s.Scan(&member.GardenID, &member.UserID, &member.Role, &member.JoinedAt)
|
||||
return member, err
|
||||
}
|
||||
|
||||
// Insert adds a user to a garden.
|
||||
func (m GardenMemberModel) Insert(member storage.GardenMember) (storage.GardenMember, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO garden_members (garden_id, user_id, role)
|
||||
SELECT $1, $2, $3
|
||||
WHERE EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1))
|
||||
RETURNING joined_at`, member.GardenID, member.UserID, member.Role,
|
||||
).Scan(&member.JoinedAt)
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, recordError(err)
|
||||
}
|
||||
if err = m.loadPermissions(&member); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// Get returns a user's membership and effective permissions in a garden.
|
||||
func (m GardenMemberModel) Get(gardenID, userID int) (storage.GardenMember, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
member, err := scanGardenMember(m.DB.QueryRowContext(ctx, `
|
||||
SELECT garden_id, user_id, role, joined_at
|
||||
FROM garden_members WHERE garden_id = $1 AND user_id = $2`, gardenID, userID))
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, recordError(err)
|
||||
}
|
||||
if err = m.loadPermissions(&member); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// GetAllForGarden lists garden members with effective permissions.
|
||||
func (m GardenMemberModel) GetAllForGarden(gardenID int) ([]storage.GardenMember, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `
|
||||
SELECT gm.garden_id, gm.user_id, gm.role, gm.joined_at, u.name, u.email
|
||||
FROM garden_members gm JOIN users u ON u.id = gm.user_id WHERE gm.garden_id = $1
|
||||
ORDER BY joined_at, user_id`, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
members := []storage.GardenMember{}
|
||||
for rows.Next() {
|
||||
var member storage.GardenMember
|
||||
err := rows.Scan(&member.GardenID, &member.UserID, &member.Role, &member.JoinedAt, &member.Name, &member.Email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members = append(members, member)
|
||||
if err = m.loadPermissions(&members[len(members)-1]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return members, rows.Err()
|
||||
}
|
||||
|
||||
// Update changes a member's garden role.
|
||||
func (m GardenMemberModel) Update(member storage.GardenMember) (storage.GardenMember, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, member.GardenID); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
var current storage.GardenRole
|
||||
if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, member.GardenID, member.UserID).Scan(¤t); err != nil {
|
||||
return storage.GardenMember{}, recordError(err)
|
||||
}
|
||||
if current == storage.GardenRoleOwner && member.Role != storage.GardenRoleOwner {
|
||||
var owners int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM garden_members WHERE garden_id=$1 AND role='owner'`, member.GardenID).Scan(&owners); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if owners < 2 {
|
||||
return storage.GardenMember{}, storage.ErrConflict
|
||||
}
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE garden_members SET role=$1 WHERE garden_id=$2 AND user_id=$3 AND EXISTS (SELECT 1 FROM roles WHERE name=$1 AND scope='garden' AND (garden_id IS NULL OR garden_id=$2))`, member.Role, member.GardenID, member.UserID)
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if count, countErr := result.RowsAffected(); countErr != nil {
|
||||
return storage.GardenMember{}, countErr
|
||||
} else if count == 0 {
|
||||
return storage.GardenMember{}, storage.ErrRecordNotFound
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if err = m.loadPermissions(&member); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// Delete removes a non-owner membership from a garden.
|
||||
func (m GardenMemberModel) Delete(gardenID, userID int) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil {
|
||||
return err
|
||||
}
|
||||
var role storage.GardenRole
|
||||
if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, userID).Scan(&role); err != nil {
|
||||
return recordError(err)
|
||||
}
|
||||
if role == storage.GardenRoleOwner {
|
||||
var owners int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM garden_members WHERE garden_id=$1 AND role='owner'`, gardenID).Scan(&owners); err != nil {
|
||||
return err
|
||||
}
|
||||
if owners < 2 {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// TransferOwnership swaps owner and member roles atomically while preserving
|
||||
// the invariant that a garden always has one owner.
|
||||
func (m GardenMemberModel) TransferOwnership(gardenID, fromUserID, toUserID int) error {
|
||||
if fromUserID == toUserID {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil {
|
||||
return err
|
||||
}
|
||||
var fromRole, toRole storage.GardenRole
|
||||
if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, fromUserID).Scan(&fromRole); err != nil {
|
||||
return recordError(err)
|
||||
}
|
||||
if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, toUserID).Scan(&toRole); err != nil {
|
||||
return recordError(err)
|
||||
}
|
||||
if fromRole != storage.GardenRoleOwner {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE garden_members SET role='owner' WHERE garden_id=$1 AND user_id=$2`, gardenID, toUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE garden_members SET role='admin' WHERE garden_id=$1 AND user_id=$2`, gardenID, fromUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
Reference in New Issue
Block a user