Files
Aether/backend/internal/acl/store.go
T
marcandClaude Opus 5 5d90c0a2d2 feat(calendar): Kalender an Nutzer und Gruppen freigeben
Nicht als eigene Tabelle, wie im Plan vorgesehen: acl_entries beantwortet
fuer Ordner und Dateien laengst dieselbe Frage -- wer darf was, direkt oder
ueber eine Gruppe. Ein zweites Rechtesystem daneben waere eine zweite
Antwort auf dieselbe Frage, mit eigener Gruppenaufloesung, eigenem
Widerruf, eigenem Pruefpfad; die Luecke dazwischen faellt erst auf, wenn
jemand sieht, was er nicht sehen soll. Migration 25 haengt deshalb nur eine
dritte Ressourcenspalte an.

Der Gewinn: Freigeben an eine **Gruppe** kostete keine Zeile extra.

Drei Entscheidungen, die nicht offensichtlich waren:

* Ein fremder Kalender steht immer unter seiner UUID, nie unter dem
  Pfadsegment des Eigentuemers -- das hat der sich ausgesucht, und zwei
  Leute duerfen denselben Namen waehlen. Sonst laegen im Kalenderbestand
  des Empfaengers zwei Kalender auf derselben Adresse.
* Alle Wege laufen durch RequireCalendar. CalDAV und Weboberflaeche sind
  zwei getrennte Zugaenge zu denselben Daten; ein Recht, das nur einer von
  beiden prueft, ist kein Recht. Im CalDAV-Teil genuegte, resolveCalendar
  umzustellen -- dort kommt jede Operation vorbei.
* Wer ein Recht geliehen hat, darf es nicht weiterverleihen: Umbenennen,
  Loeschen und Freigeben bleiben auch mit Schreibrecht beim Eigentuemer.

Oberflaeche: fremde Kalender tragen Eigentuemer und "(nur lesen)", das
Menue zeigt nur, was auch geht, und ein Termin laesst sich gar nicht erst
in einen Kalender ziehen, den der Server danach ablehnen wuerde.

scripts/kalender-teilen-selbsttest.py, 41 Pruefungen, jede ueber beide
Zugaenge. Er beginnt damit, dass der Empfaenger vorher nichts sieht --
ohne diesen Anfang beweist der Rest nichts.

Dabei hatte der Test selbst eine Falle: Mit festem Testdatum lief die
Terminliste aus ihrem Standardzeitraum, und die Pruefung "beim Eigentuemer
ist nichts angekommen" bestand **leer**.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:46:18 +02:00

324 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package acl
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
var ErrSelfShare = errors.New("cannot share with yourself")
// Entry ist eine Freigabe, wie sie der Eigentümer im Dialog sieht.
type Entry struct {
ID uuid.UUID `json:"id"`
SubjectType string `json:"subject_type"` // "user" | "group"
SubjectID uuid.UUID `json:"subject_id"`
SubjectName string `json:"subject_name"`
Permission Permission `json:"permission"`
CreatedAt time.Time `json:"created_at"`
}
// SharedResource ist ein Eintrag in „Geteilt mit mir".
type SharedResource struct {
Type string `json:"type"` // "folder" | "file"
ID uuid.UUID `json:"id"`
Name string `json:"name"`
OwnerID uuid.UUID `json:"owner_id"`
OwnerName string `json:"owner_name"`
Permission Permission `json:"permission"`
Size int64 `json:"size"`
MimeType string `json:"mime_type"`
SharedAt time.Time `json:"shared_at"`
// ViaGroup nennt die Gruppe, über die der Zugriff kommt – sonst wundert
// man sich, wo die Freigabe herkommt, die man nie persönlich bekommen hat.
ViaGroup string `json:"via_group"`
}
// ── Freigaben verwalten ──────────────────────────────────────────────────────
// resource benennt, worum es bei einer Freigabe geht: genau eines der drei
// Felder ist gesetzt. Als Typ statt als drei Zeigerparameter, seit der
// Kalender dazukam – drei nebeneinanderstehende `*uuid.UUID` in einer
// Signatur sind eine Einladung, sie zu vertauschen.
type resource struct{ folder, file, calendar *uuid.UUID }
// GrantFolder gibt einen Ordner frei. subjectIsGroup entscheidet, ob subjectID
// ein Nutzer oder eine Gruppe ist.
func (s *Store) GrantFolder(ctx context.Context, folderID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
return s.grant(ctx, resource{folder: &folderID}, subjectID, subjectIsGroup, perm, grantedBy)
}
func (s *Store) GrantFile(ctx context.Context, fileID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
return s.grant(ctx, resource{file: &fileID}, subjectID, subjectIsGroup, perm, grantedBy)
}
// GrantCalendar gibt einen Kalender frei – an ein Konto oder an eine Gruppe.
// Dass Gruppen ohne Zutun mitfunktionieren, ist der Grund, warum Kalender in
// *dieser* Tabelle liegen und nicht in einer eigenen.
func (s *Store) GrantCalendar(ctx context.Context, calendarID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
return s.grant(ctx, resource{calendar: &calendarID}, subjectID, subjectIsGroup, perm, grantedBy)
}
func (s *Store) grant(ctx context.Context, res resource, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
if !perm.valid() {
return nil, fmt.Errorf("invalid permission %q", perm)
}
// Sich selbst freigeben ist sinnlos und würde in „Geteilt mit mir" die
// eigenen Ordner doppelt anzeigen.
if !subjectIsGroup && subjectID == grantedBy {
return nil, ErrSelfShare
}
var subjUser, subjGroup *uuid.UUID
if subjectIsGroup {
subjGroup = &subjectID
} else {
subjUser = &subjectID
}
var e Entry
err := s.db.QueryRow(ctx,
`INSERT INTO acl_entries
(subject_user_id, subject_group_id,
resource_folder_id, resource_file_id, resource_calendar_id, permission, granted_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, permission, created_at`,
subjUser, subjGroup, res.folder, res.file, res.calendar, perm, grantedBy,
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
// Eine zweite Freigabe an denselben Empfänger ist keine neue, sondern eine
// Änderung des Rechts – sonst müsste man erst widerrufen, um von „lesen"
// auf „schreiben" zu wechseln. Die vier partiellen Unique-Indizes lassen
// sich nicht in einer einzigen ON-CONFLICT-Klausel abdecken, deshalb hier
// über den Fehlercode.
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return s.updateExisting(ctx, res, subjUser, subjGroup, perm)
}
if err != nil {
return nil, fmt.Errorf("grant access: %w", err)
}
e.SubjectID = subjectID
e.SubjectType = subjectTypeName(subjectIsGroup)
return &e, nil
}
func (s *Store) updateExisting(ctx context.Context, res resource, subjUser, subjGroup *uuid.UUID, perm Permission) (*Entry, error) {
var e Entry
err := s.db.QueryRow(ctx,
`UPDATE acl_entries SET permission = $1
WHERE resource_folder_id IS NOT DISTINCT FROM $2
AND resource_file_id IS NOT DISTINCT FROM $3
AND resource_calendar_id IS NOT DISTINCT FROM $4
AND subject_user_id IS NOT DISTINCT FROM $5
AND subject_group_id IS NOT DISTINCT FROM $6
RETURNING id, permission, created_at`,
perm, res.folder, res.file, res.calendar, subjUser, subjGroup,
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("update access: %w", err)
}
if subjGroup != nil {
e.SubjectID, e.SubjectType = *subjGroup, "group"
} else if subjUser != nil {
e.SubjectID, e.SubjectType = *subjUser, "user"
}
return &e, nil
}
// Revoke entfernt eine Freigabe. ownerID stellt sicher, dass nur der
// Eigentümer der Ressource widerrufen kann.
func (s *Store) Revoke(ctx context.Context, entryID, ownerID uuid.UUID) error {
tag, err := s.db.Exec(ctx,
`DELETE FROM acl_entries e
WHERE e.id = $1
AND ( EXISTS (SELECT 1 FROM folders f WHERE f.id = e.resource_folder_id AND f.owner_id = $2)
OR EXISTS (SELECT 1 FROM files x WHERE x.id = e.resource_file_id AND x.owner_id = $2)
OR EXISTS (SELECT 1 FROM cal_calendars c WHERE c.id = e.resource_calendar_id AND c.owner_id = $2) )`,
entryID, ownerID)
if err != nil {
return fmt.Errorf("revoke access: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ListForFolder / ListForFile zeigen dem Eigentümer, wer aktuell Zugriff hat.
func (s *Store) ListForFolder(ctx context.Context, folderID uuid.UUID) ([]Entry, error) {
return s.list(ctx, `e.resource_folder_id = $1`, folderID)
}
func (s *Store) ListForFile(ctx context.Context, fileID uuid.UUID) ([]Entry, error) {
return s.list(ctx, `e.resource_file_id = $1`, fileID)
}
func (s *Store) ListForCalendar(ctx context.Context, calendarID uuid.UUID) ([]Entry, error) {
return s.list(ctx, `e.resource_calendar_id = $1`, calendarID)
}
func (s *Store) list(ctx context.Context, where string, resourceID uuid.UUID) ([]Entry, error) {
rows, err := s.db.Query(ctx, `
SELECT e.id,
CASE WHEN e.subject_group_id IS NOT NULL THEN 'group' ELSE 'user' END,
COALESCE(e.subject_group_id, e.subject_user_id),
COALESCE(g.name, NULLIF(u.display_name, ''), u.username, ''),
e.permission, e.created_at
FROM acl_entries e
LEFT JOIN users u ON u.id = e.subject_user_id
LEFT JOIN groups g ON g.id = e.subject_group_id
WHERE `+where+`
ORDER BY e.created_at`, resourceID)
if err != nil {
return nil, fmt.Errorf("list access entries: %w", err)
}
defer rows.Close()
out := []Entry{}
for rows.Next() {
var e Entry
if err := rows.Scan(&e.ID, &e.SubjectType, &e.SubjectID, &e.SubjectName,
&e.Permission, &e.CreatedAt); err != nil {
return nil, fmt.Errorf("scan access entry: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
// ListSharedWith liefert alles, was userID freigegeben bekommen hat – direkt
// oder über eine Gruppe. Das ist der Inhalt von „Geteilt mit mir".
//
// Nur die obersten freigegebenen Knoten erscheinen: Wer einen Ordner und darin
// eine Datei freigegeben bekommt, soll den Ordner sehen und die Datei darin,
// nicht beides nebeneinander.
func (s *Store) ListSharedWith(ctx context.Context, userID uuid.UUID) ([]SharedResource, error) {
const q = `
SELECT 'folder', f.id, f.name, f.owner_id,
COALESCE(NULLIF(o.display_name, ''), o.username),
e.permission, 0::bigint, '', e.created_at,
COALESCE(g.name, '')
FROM acl_entries e
JOIN folders f ON f.id = e.resource_folder_id
JOIN users o ON o.id = f.owner_id
LEFT JOIN groups g ON g.id = e.subject_group_id
WHERE (e.subject_user_id = $1
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
-- Ordner weglassen, deren Elternteil ohnehin schon freigegeben ist.
AND NOT EXISTS (
SELECT 1
FROM acl_entries pe
JOIN folders pf ON pf.id = pe.resource_folder_id
WHERE pf.id = f.parent_id
AND (pe.subject_user_id = $1
OR pe.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
)
UNION ALL
SELECT 'file', x.id, x.name, x.owner_id,
COALESCE(NULLIF(o.display_name, ''), o.username),
e.permission, x.size, x.mime_type, e.created_at,
COALESCE(g.name, '')
FROM acl_entries e
JOIN files x ON x.id = e.resource_file_id
JOIN users o ON o.id = x.owner_id
LEFT JOIN groups g ON g.id = e.subject_group_id
WHERE x.is_deleted = false
AND (e.subject_user_id = $1
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
-- Datei weglassen, wenn ihr Ordner bereits freigegeben ist.
AND NOT EXISTS (
SELECT 1
FROM acl_entries fe
WHERE fe.resource_folder_id = x.folder_id
AND (fe.subject_user_id = $1
OR fe.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
)
ORDER BY 1, 3`
rows, err := s.db.Query(ctx, q, userID)
if err != nil {
return nil, fmt.Errorf("list shared resources: %w", err)
}
defer rows.Close()
out := []SharedResource{}
for rows.Next() {
var r SharedResource
if err := rows.Scan(&r.Type, &r.ID, &r.Name, &r.OwnerID, &r.OwnerName,
&r.Permission, &r.Size, &r.MimeType, &r.SharedAt, &r.ViaGroup); err != nil {
return nil, fmt.Errorf("scan shared resource: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// ── Verzeichnis für den Freigabedialog ───────────────────────────────────────
type Recipient struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "user" | "group"
}
// Recipients listet mögliche Empfänger. Bewusst nur ID, Anzeigename und Art –
// Quota, Admin-Status und E-Mail gehen einen normalen Nutzer nichts an, auch
// wenn er teilen darf.
func (s *Store) Recipients(ctx context.Context, exclude uuid.UUID) ([]Recipient, error) {
rows, err := s.db.Query(ctx, `
SELECT id, COALESCE(NULLIF(display_name, ''), username), 'user'
FROM users WHERE id <> $1
UNION ALL
SELECT id, name, 'group' FROM groups
ORDER BY 3 DESC, 2`, exclude)
if err != nil {
return nil, fmt.Errorf("list recipients: %w", err)
}
defer rows.Close()
out := []Recipient{}
for rows.Next() {
var r Recipient
if err := rows.Scan(&r.ID, &r.Name, &r.Type); err != nil {
return nil, fmt.Errorf("scan recipient: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
func subjectTypeName(isGroup bool) string {
if isGroup {
return "group"
}
return "user"
}
// CalendarOwner liefert, wem ein Kalender gehört – die Frage vor jeder
// Verwaltung seiner Freigaben.
func (s *Store) CalendarOwner(ctx context.Context, calendarID uuid.UUID) (uuid.UUID, error) {
var owner uuid.UUID
err := s.db.QueryRow(ctx,
`SELECT owner_id FROM cal_calendars WHERE id = $1`, calendarID).Scan(&owner)
if errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, ErrNotFound
}
if err != nil {
return uuid.Nil, fmt.Errorf("load calendar owner: %w", err)
}
return owner, nil
}