Files
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

175 lines
5.6 KiB
Go
Raw Permalink 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 calendar
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"aether/internal/acl"
)
// Wer darf was mit einem Kalender.
//
// **Alle Wege laufen hier zusammen.** CalDAV und die Weboberfläche sind zwei
// völlig verschiedene Zugänge zu denselben Daten – ein Recht, das nur einer
// von beiden prüft, ist kein Recht. Deshalb gibt es genau eine Antwort auf
// „darf dieses Konto diesen Kalender", und beide Zugänge holen sie hier.
// Access ist ein Kalender samt dem, was der Anfragende damit darf.
type Access struct {
Calendar
Permission acl.Permission `json:"permission"`
// Shared: Der Kalender gehört jemand anderem.
Shared bool `json:"shared"`
// OwnerName steht in der Oberfläche neben dem Namen – ein fremder
// Kalender ohne sichtbaren Absender ist ein Rätsel.
OwnerName string `json:"owner_name,omitempty"`
// ViaGroup nennt die Gruppe, über die der Zugriff kommt.
ViaGroup string `json:"via_group,omitempty"`
}
func (a *Access) CanWrite() bool { return a.Permission == acl.PermWrite }
// Ref ist die Adresse in CalDAV.
//
// Bei einem **fremden** Kalender immer die UUID, nie das Pfadsegment: Das
// Segment hat sich der Eigentümer ausgesucht ("…/arbeit/"), und zwei Leute
// dürfen denselben Namen wählen. Unter dem Kalenderbestand des Empfängers
// stünden dann zwei Kalender an derselben Adresse – und welcher gemeint ist,
// entschiede der Zufall.
func (a *Access) Ref() string {
if a.Shared {
return a.Calendar.ID.String()
}
return a.Calendar.Ref()
}
// ListAccessible liefert alles, was userID sehen darf: die eigenen Kalender
// und die ihm freigegebenen. Eigene zuerst – sie sind die, die man sucht.
func (s *Store) ListAccessible(ctx context.Context, userID uuid.UUID) ([]Access, error) {
own, err := s.ListCalendars(ctx, userID)
if err != nil {
return nil, err
}
out := make([]Access, 0, len(own))
for i := range own {
out = append(out, Access{Calendar: own[i], Permission: acl.PermWrite})
}
if s.rights == nil {
return out, nil
}
geteilt, err := s.rights.SharedCalendars(ctx, userID)
if err != nil {
return nil, fmt.Errorf("list shared calendars: %w", err)
}
for _, sc := range geteilt {
cal, err := s.getCalendarAny(ctx, sc.CalendarID)
if errors.Is(err, ErrNotFound) {
continue // zwischenzeitlich gelöscht
}
if err != nil {
return nil, err
}
out = append(out, Access{
Calendar: *cal,
Permission: sc.Permission,
Shared: true,
OwnerName: sc.OwnerName,
ViaGroup: sc.ViaGroup,
})
}
return out, nil
}
// ResolveRef findet den Kalender hinter einer CalDAV-Adresse – eigener oder
// freigegebener.
//
// Die Reihenfolge ist Absicht: **erst die eigenen.** Sonst könnte ein fremder
// Kalender, dessen UUID zufällig wie ein eigenes Pfadsegment aussieht, den
// eigenen verdecken.
func (s *Store) ResolveRef(ctx context.Context, userID uuid.UUID, ref string) (*Access, error) {
cal, err := s.GetCalendarByRef(ctx, userID, ref)
if err == nil {
return &Access{Calendar: *cal, Permission: acl.PermWrite}, nil
}
if !errors.Is(err, ErrNotFound) {
return nil, err
}
// Ein freigegebener Kalender steht unter seiner UUID. Alles andere kann
// hier nichts mehr treffen.
id, parseErr := uuid.Parse(ref)
if parseErr != nil {
return nil, ErrNotFound
}
return s.RequireCalendar(ctx, userID, id, false)
}
// RequireCalendar ist die Prüfung, die jeder schreibende und lesende Zugriff
// durchläuft – aus CalDAV wie aus der Weboberfläche.
//
// Ohne Zugriff kommt **ErrNotFound**, nicht „verboten": Ein 403 verriete, dass
// es den Kalender gibt. Dieselbe Regel wie überall sonst in Aether.
func (s *Store) RequireCalendar(ctx context.Context, userID, calendarID uuid.UUID, needWrite bool) (*Access, error) {
cal, err := s.getCalendarAny(ctx, calendarID)
if err != nil {
return nil, err
}
if cal.OwnerID == userID {
return &Access{Calendar: *cal, Permission: acl.PermWrite}, nil
}
if s.rights == nil {
return nil, ErrNotFound
}
perm, err := s.rights.ResolveCalendar(ctx, userID, calendarID)
if errors.Is(err, acl.ErrNoAccess) || errors.Is(err, acl.ErrNotFound) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if needWrite && perm != acl.PermWrite {
// Hier **darf** es ein 403 sein: Dass es den Kalender gibt, weiß der
// Anfragende bereits – er darf ihn ja lesen.
return nil, ErrReadOnly
}
a := &Access{Calendar: *cal, Permission: perm, Shared: true}
// Name des Eigentümers nur nachschlagen, wenn er auch angezeigt wird.
if name, err := s.ownerName(ctx, cal.OwnerID); err == nil {
a.OwnerName = name
}
return a, nil
}
// ErrReadOnly: Der Kalender ist sichtbar, aber nur lesend freigegeben.
var ErrReadOnly = errors.New("calendar: read-only share")
// getCalendarAny lädt einen Kalender **ohne** Rechteprüfung. Privat, und das
// muss so bleiben: Jeder Aufruf von außen ginge an der Prüfung vorbei.
func (s *Store) getCalendarAny(ctx context.Context, id uuid.UUID) (*Calendar, error) {
var c Calendar
err := s.db.QueryRow(ctx,
`SELECT id, owner_id, name, color, description, supports_todo, timezone,
COALESCE(path_segment, ''), ctag, sync_floor, created_at
FROM cal_calendars WHERE id = $1`, id,
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo,
&c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
if err != nil {
return nil, ErrNotFound
}
return &c, nil
}
func (s *Store) ownerName(ctx context.Context, ownerID uuid.UUID) (string, error) {
var name string
err := s.db.QueryRow(ctx,
`SELECT COALESCE(NULLIF(display_name, ''), username) FROM users WHERE id = $1`,
ownerID).Scan(&name)
return name, err
}