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:
1 parent
8fdde182b7
commit
5d90c0a2d2
21 files changed
+1031
-60
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user