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>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-12 13:46:18 +02:00
1 parent 8fdde182b7
commit 5d90c0a2d2
21 files changed
+1031 -60

No files matched your search

+116
View File
@@ -349,3 +349,119 @@ func stronger(a, b Permission) Permission {
}
return ""
}
// ── Kalender ─────────────────────────────────────────────────────────────────
//
// Kalender liegen in derselben Rechtetabelle wie Ordner und Dateien. Nicht aus
// Sparsamkeit, sondern weil zwei Tabellen zwei Antworten auf dieselbe Frage
// wären – und die Lücke zwischen ihnen fällt erst auf, wenn jemand sieht, was
// er nicht sehen soll. Die Auflösung über Gruppen ist deshalb wörtlich
// dieselbe Bedingung wie bei Ordnern.
// ResolveCalendar klärt, was userID mit diesem Kalender darf.
//
// Der Eigentümer darf schreiben. Alle anderen nur, wenn es eine Freigabe gibt –
// direkt oder über eine Gruppe, in der sie sind. Gibt es keine, ist die
// Antwort ErrNoAccess, und der Aufrufer macht daraus ein 404: Ein 403 verriete,
// dass es den Kalender gibt.
func (s *Store) ResolveCalendar(ctx context.Context, userID, calendarID uuid.UUID) (Permission, error) {
var ownerID uuid.UUID
err := s.db.QueryRow(ctx,
`SELECT owner_id FROM cal_calendars WHERE id = $1`, calendarID).Scan(&ownerID)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("load calendar owner: %w", err)
}
if ownerID == userID {
return PermWrite, nil
}
rows, err := s.db.Query(ctx,
`SELECT e.permission
FROM acl_entries e
WHERE e.resource_calendar_id = $1
AND ( e.subject_user_id = $2
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $2) )`,
calendarID, userID)
if err != nil {
return "", fmt.Errorf("resolve calendar permission: %w", err)
}
defer rows.Close()
// Mehrere Wege zum selben Kalender sind möglich – persönlich *und* über
// eine Gruppe. Dann gilt das weitergehende Recht; alles andere wäre für
// den Betroffenen nicht nachvollziehbar.
var best Permission
for rows.Next() {
var p Permission
if err := rows.Scan(&p); err != nil {
return "", fmt.Errorf("scan permission: %w", err)
}
if p == PermWrite {
best = PermWrite
} else if best == "" {
best = p
}
}
if err := rows.Err(); err != nil {
return "", err
}
if best == "" {
return "", ErrNoAccess
}
return best, nil
}
// SharedCalendar ist ein fremder Kalender, den jemand sehen darf.
type SharedCalendar struct {
CalendarID uuid.UUID
Permission Permission
OwnerName string
// ViaGroup nennt die Gruppe, über die der Zugriff kommt – sonst wundert
// man sich über einen Kalender, den einem nie jemand persönlich gab.
ViaGroup string
}
// SharedCalendars liefert alle **fremden** Kalender, die userID sehen darf.
// Die eigenen stehen nicht darin; die kennt der Kalenderteil selbst.
func (s *Store) SharedCalendars(ctx context.Context, userID uuid.UUID) ([]SharedCalendar, error) {
rows, err := s.db.Query(ctx,
`SELECT c.id, e.permission,
COALESCE(NULLIF(o.display_name, ''), o.username, ''),
COALESCE(g.name, '')
FROM acl_entries e
JOIN cal_calendars c ON c.id = e.resource_calendar_id
JOIN users o ON o.id = c.owner_id
LEFT JOIN groups g ON g.id = e.subject_group_id
WHERE c.owner_id <> $1
AND ( e.subject_user_id = $1
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1) )
ORDER BY o.username, c.created_at`,
userID)
if err != nil {
return nil, fmt.Errorf("list shared calendars: %w", err)
}
defer rows.Close()
// Zwei Wege zum selben Kalender ergeben zwei Zeilen – angezeigt wird er
// einmal, mit dem weitergehenden Recht.
byID := map[uuid.UUID]int{}
out := []SharedCalendar{}
for rows.Next() {
var sc SharedCalendar
if err := rows.Scan(&sc.CalendarID, &sc.Permission, &sc.OwnerName, &sc.ViaGroup); err != nil {
return nil, fmt.Errorf("scan shared calendar: %w", err)
}
if i, da := byID[sc.CalendarID]; da {
if sc.Permission == PermWrite {
out[i].Permission = PermWrite
}
continue
}
byID[sc.CalendarID] = len(out)
out = append(out, sc)
}
return out, rows.Err()
}
+57
View File
@@ -89,6 +89,63 @@ func (h *Handler) ownedFile(w http.ResponseWriter, r *http.Request) (uuid.UUID,
return id, user.ID, true
}
// ownedCalendar: Nur der Eigentümer verwaltet die Freigaben seines Kalenders.
// Wer ihn freigegeben *bekommen* hat – auch schreibend –, darf ihn nicht
// weiterreichen. Ein Recht weiterzugeben, das man selbst nur geliehen hat,
// ist etwas anderes als es zu benutzen.
func (h *Handler) ownedCalendar(w http.ResponseWriter, r *http.Request) (uuid.UUID, uuid.UUID, bool) {
user := auth.UserFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid calendar id")
return uuid.Nil, uuid.Nil, false
}
owner, err := h.store.CalendarOwner(r.Context(), id)
if err != nil {
response.Error(w, http.StatusNotFound, "calendar not found")
return uuid.Nil, uuid.Nil, false
}
if owner != user.ID {
// 404 statt 403: Wer den Kalender nicht besitzt, soll aus der Antwort
// nicht erfahren, dass es ihn gibt.
response.Error(w, http.StatusNotFound, "calendar not found")
return uuid.Nil, uuid.Nil, false
}
return id, user.ID, true
}
// GET /api/v1/calendars/{id}/acl
func (h *Handler) ListCalendarACL(w http.ResponseWriter, r *http.Request) {
id, _, ok := h.ownedCalendar(w, r)
if !ok {
return
}
entries, err := h.store.ListForCalendar(r.Context(), id)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load shares")
return
}
response.JSON(w, http.StatusOK, entries)
}
// POST /api/v1/calendars/{id}/acl
func (h *Handler) GrantCalendarACL(w http.ResponseWriter, r *http.Request) {
id, owner, ok := h.ownedCalendar(w, r)
if !ok {
return
}
req, ok := decodeGrant(w, r)
if !ok {
return
}
entry, err := h.store.GrantCalendar(r.Context(), id, req.subjectID, req.isGroup, req.perm, owner)
if err == nil {
h.logShare(r, audit.ActionShareGrant, "calendar", id.String(),
string(req.perm)+" for "+req.subjectType()+" "+req.subjectID.String())
}
writeGrantResult(w, entry, err)
}
func userFrom(r *http.Request) uuid.UUID {
if u := auth.UserFromContext(r.Context()); u != nil {
return u.ID
+50 -15
View File
@@ -41,17 +41,30 @@ type SharedResource struct {
// ── 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, &folderID, nil, subjectID, subjectIsGroup, perm, grantedBy)
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, nil, &fileID, subjectID, subjectIsGroup, perm, grantedBy)
return s.grant(ctx, resource{file: &fileID}, subjectID, subjectIsGroup, perm, grantedBy)
}
func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
// 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)
}
@@ -71,10 +84,11 @@ func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectI
var e Entry
err := s.db.QueryRow(ctx,
`INSERT INTO acl_entries
(subject_user_id, subject_group_id, resource_folder_id, resource_file_id, permission, granted_by)
VALUES ($1, $2, $3, $4, $5, $6)
(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, folderID, fileID, perm, grantedBy,
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
@@ -84,7 +98,7 @@ func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectI
// über den Fehlercode.
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return s.updateExisting(ctx, folderID, fileID, subjUser, subjGroup, perm)
return s.updateExisting(ctx, res, subjUser, subjGroup, perm)
}
if err != nil {
return nil, fmt.Errorf("grant access: %w", err)
@@ -95,16 +109,17 @@ func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectI
return &e, nil
}
func (s *Store) updateExisting(ctx context.Context, folderID, fileID, subjUser, subjGroup *uuid.UUID, perm Permission) (*Entry, error) {
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 subject_user_id IS NOT DISTINCT FROM $4
AND subject_group_id IS NOT DISTINCT FROM $5
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, folderID, fileID, subjUser, subjGroup,
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
@@ -126,8 +141,9 @@ 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) )`,
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)
@@ -147,6 +163,10 @@ func (s *Store) ListForFile(ctx context.Context, fileID uuid.UUID) ([]Entry, err
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,
@@ -286,3 +306,18 @@ func subjectTypeName(isGroup bool) string {
}
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
}