Initial commit
CI / test (push) Canceled after 0s

This commit is contained in:
2026-09-12 22:22:17 +02:00
commit 904d14b64c
314 changed files with 31884 additions and 0 deletions
+222
View File
@@ -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(&current); 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()
}