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>
468 lines
15 KiB
Go
468 lines
15 KiB
Go
// Package acl regelt interne Freigaben: Ordner und Dateien an andere Nutzer
|
||
// oder Gruppen geben, ohne dass Daten umziehen.
|
||
//
|
||
// Das Zugriffsmodell in einem Satz: Zugriff hat, wem die Ressource gehört –
|
||
// oder wer einen Freigabeeintrag darauf besitzt, direkt oder über eine Gruppe,
|
||
// auf der Ressource selbst oder auf einem Ordner darüber.
|
||
//
|
||
// Die Vererbung nach unten ist der Grund, warum das nicht mit einer einfachen
|
||
// Abfrage geht: Wer einen Ordner freigibt, meint dessen ganzen Inhalt – sonst
|
||
// müsste man jede Datei einzeln freigeben, und jede neu hinzugefügte gleich
|
||
// mit. Deshalb läuft die Prüfung den Ordnerbaum nach oben, bis sie einen
|
||
// Eintrag findet.
|
||
package acl
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
)
|
||
|
||
var (
|
||
// ErrNoAccess: weder Eigentümer noch Freigabe. Handler antworten damit
|
||
// bewusst „nicht gefunden" statt „verboten" – sonst verriete die Antwort,
|
||
// dass die ID überhaupt existiert.
|
||
ErrNoAccess = errors.New("no access to this resource")
|
||
// ErrReadOnly: Zugriff ja, Schreiben nein.
|
||
ErrReadOnly = errors.New("read-only access")
|
||
ErrNotFound = errors.New("resource not found")
|
||
)
|
||
|
||
// Permission ist absichtlich klein gehalten. Feinere Rechte (löschen vs.
|
||
// ändern) brächten Fragen mit sich, die für eine persönliche Cloud niemand
|
||
// stellt.
|
||
type Permission string
|
||
|
||
const (
|
||
PermRead Permission = "read"
|
||
PermWrite Permission = "write"
|
||
)
|
||
|
||
func (p Permission) valid() bool { return p == PermRead || p == PermWrite }
|
||
|
||
// Access beschreibt das Ergebnis einer Zugriffsprüfung.
|
||
type Access struct {
|
||
// OwnerID ist der *Prinzipal* der Ressource – ein Konto oder eine Gruppe,
|
||
// nicht zwingend der Anfragende. Alle Speicher- und Datenbankoperationen
|
||
// laufen weiter über ihn, denn dort liegen die Daten.
|
||
//
|
||
// Ein Prinzipal statt eines Eigentümers, weil Gruppenordner niemandem
|
||
// persönlich gehören. Die Abfragen prüfen deshalb `owner_id = $1 OR
|
||
// group_id = $1`; UUIDs sind global eindeutig, eine Gruppen-ID kann also
|
||
// nie versehentlich auf ein Konto passen.
|
||
OwnerID uuid.UUID
|
||
// IsGroup unterscheidet die beiden Fälle dort, wo es darauf ankommt:
|
||
// beim Anlegen (welche Spalte wird gesetzt) und beim Speicherpfad.
|
||
IsGroup bool
|
||
// OwnerUsername, weil Speicherpfade daraus gebaut werden
|
||
// (`users/{name}/home/…`). Wer in einem geliehenen Ordner arbeitet, schreibt
|
||
// in den Bereich des Eigentümers, nicht in seinen eigenen.
|
||
OwnerUsername string
|
||
// OwnerQuota ist das Kontingent des Eigentümers. Wer in einen geliehenen
|
||
// Ordner hochlädt, verbraucht dessen Platz – die Bytes liegen ja in dessen
|
||
// Bereich. Alles andere wäre ein Weg, fremde Kontingente zu umgehen.
|
||
OwnerQuota int64
|
||
// Permission ist das, was der Anfragende darf.
|
||
Permission Permission
|
||
// Shared unterscheidet eigenen Besitz von geliehenem Zugriff.
|
||
Shared bool
|
||
}
|
||
|
||
func (a Access) CanWrite() bool { return a.Permission == PermWrite }
|
||
|
||
// OwnerRef und GroupRef liefern die Werte für die Spalten owner_id / group_id.
|
||
// Genau einer ist gesetzt – so verlangt es die CHECK-Bedingung.
|
||
func (a Access) OwnerRef() *uuid.UUID {
|
||
if a.IsGroup {
|
||
return nil
|
||
}
|
||
id := a.OwnerID
|
||
return &id
|
||
}
|
||
|
||
func (a Access) GroupRef() *uuid.UUID {
|
||
if !a.IsGroup {
|
||
return nil
|
||
}
|
||
id := a.OwnerID
|
||
return &id
|
||
}
|
||
|
||
// StorageRoot ist das Wurzelsegment für Speicherpfade. Gruppendaten liegen
|
||
// bewusst nicht unter `users/…`: Auf der Platte soll man ansehen können, dass
|
||
// ein Ordner keinem Konto gehört – sonst sucht man die Daten einer Gruppe im
|
||
// Verzeichnis eines Nutzers, der zufällig ihr Ersteller war.
|
||
func (a Access) StorageRoot() string {
|
||
if a.IsGroup {
|
||
return "groups/" + a.OwnerID.String()
|
||
}
|
||
return "users/" + a.OwnerUsername
|
||
}
|
||
|
||
type Store struct {
|
||
db *pgxpool.Pool
|
||
}
|
||
|
||
func NewStore(db *pgxpool.Pool) *Store {
|
||
return &Store{db: db}
|
||
}
|
||
|
||
// ── Zugriffsauflösung ────────────────────────────────────────────────────────
|
||
|
||
// ResolveFolder klärt, was userID mit diesem Ordner darf.
|
||
func (s *Store) ResolveFolder(ctx context.Context, userID, folderID uuid.UUID) (Access, error) {
|
||
// Gruppenordner haben keinen Eigentümer, deshalb beide Fälle in einer
|
||
// Abfrage. COALESCE macht die Gruppen-ID zum Prinzipal, wenn owner_id leer
|
||
// ist – genau die ID, mit der die Store-Abfragen weiterarbeiten.
|
||
var principal uuid.UUID
|
||
var isGroup bool
|
||
var ownerName string
|
||
var quota int64
|
||
err := s.db.QueryRow(ctx,
|
||
`SELECT COALESCE(f.owner_id, f.group_id),
|
||
f.group_id IS NOT NULL,
|
||
COALESCE(u.username, ''),
|
||
COALESCE(u.quota_bytes, g.quota_bytes, 0)
|
||
FROM folders f
|
||
LEFT JOIN users u ON u.id = f.owner_id
|
||
LEFT JOIN groups g ON g.id = f.group_id
|
||
WHERE f.id = $1`, folderID).Scan(&principal, &isGroup, &ownerName, "a)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return Access{}, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return Access{}, fmt.Errorf("load folder principal: %w", err)
|
||
}
|
||
|
||
if isGroup {
|
||
// Mitgliedschaft ersetzt hier die Freigabe: Wer in der Gruppe ist, darf
|
||
// schreiben; wer sie verlässt, verliert den Zugriff im selben Moment.
|
||
member, err := s.isGroupMember(ctx, userID, principal)
|
||
if err != nil {
|
||
return Access{}, err
|
||
}
|
||
if !member {
|
||
return Access{}, ErrNoAccess
|
||
}
|
||
return Access{
|
||
OwnerID: principal, IsGroup: true, OwnerQuota: quota,
|
||
Permission: PermWrite, Shared: true,
|
||
}, nil
|
||
}
|
||
|
||
if principal == userID {
|
||
return Access{OwnerID: principal, OwnerUsername: ownerName, OwnerQuota: quota, Permission: PermWrite}, nil
|
||
}
|
||
ownerID, ownerQuota := principal, quota
|
||
|
||
perm, err := s.bestPermissionForFolder(ctx, userID, folderID)
|
||
if err != nil {
|
||
return Access{}, err
|
||
}
|
||
if perm == "" {
|
||
return Access{}, ErrNoAccess
|
||
}
|
||
return Access{OwnerID: ownerID, OwnerUsername: ownerName, OwnerQuota: ownerQuota, Permission: perm, Shared: true}, nil
|
||
}
|
||
|
||
// ResolveFile klärt, was userID mit dieser Datei darf. Geerbt wird über den
|
||
// Ordner, in dem sie liegt.
|
||
func (s *Store) ResolveFile(ctx context.Context, userID, fileID uuid.UUID) (Access, error) {
|
||
var principal uuid.UUID
|
||
var isGroup bool
|
||
var ownerName string
|
||
var quota int64
|
||
var folderID *uuid.UUID
|
||
err := s.db.QueryRow(ctx,
|
||
`SELECT COALESCE(x.owner_id, x.group_id),
|
||
x.group_id IS NOT NULL,
|
||
COALESCE(u.username, ''),
|
||
COALESCE(u.quota_bytes, g.quota_bytes, 0),
|
||
x.folder_id
|
||
FROM files x
|
||
LEFT JOIN users u ON u.id = x.owner_id
|
||
LEFT JOIN groups g ON g.id = x.group_id
|
||
WHERE x.id = $1`, fileID).Scan(&principal, &isGroup, &ownerName, "a, &folderID)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return Access{}, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return Access{}, fmt.Errorf("load file principal: %w", err)
|
||
}
|
||
|
||
if isGroup {
|
||
member, err := s.isGroupMember(ctx, userID, principal)
|
||
if err != nil {
|
||
return Access{}, err
|
||
}
|
||
if !member {
|
||
return Access{}, ErrNoAccess
|
||
}
|
||
return Access{
|
||
OwnerID: principal, IsGroup: true, OwnerQuota: quota,
|
||
Permission: PermWrite, Shared: true,
|
||
}, nil
|
||
}
|
||
|
||
if principal == userID {
|
||
return Access{OwnerID: principal, OwnerUsername: ownerName, OwnerQuota: quota, Permission: PermWrite}, nil
|
||
}
|
||
ownerID, ownerQuota := principal, quota
|
||
|
||
// Direkte Freigabe auf der Datei.
|
||
perm, err := s.directFilePermission(ctx, userID, fileID)
|
||
if err != nil {
|
||
return Access{}, err
|
||
}
|
||
// Geerbt aus dem Ordner darüber. Das stärkere Recht gewinnt: eine
|
||
// Schreibfreigabe auf dem Ordner soll nicht dadurch entwertet werden, dass
|
||
// jemand zusätzlich eine Lesefreigabe auf einer einzelnen Datei erteilt hat.
|
||
if folderID != nil && perm != PermWrite {
|
||
inherited, err := s.bestPermissionForFolder(ctx, userID, *folderID)
|
||
if err != nil {
|
||
return Access{}, err
|
||
}
|
||
perm = stronger(perm, inherited)
|
||
}
|
||
if perm == "" {
|
||
return Access{}, ErrNoAccess
|
||
}
|
||
return Access{OwnerID: ownerID, OwnerUsername: ownerName, OwnerQuota: ownerQuota, Permission: perm, Shared: true}, nil
|
||
}
|
||
|
||
// RequireFolderWrite / RequireFileWrite sind die bequeme Form für Handler, die
|
||
// ändern wollen.
|
||
func (s *Store) RequireFolderWrite(ctx context.Context, userID, folderID uuid.UUID) (Access, error) {
|
||
a, err := s.ResolveFolder(ctx, userID, folderID)
|
||
if err != nil {
|
||
return a, err
|
||
}
|
||
if !a.CanWrite() {
|
||
return a, ErrReadOnly
|
||
}
|
||
return a, nil
|
||
}
|
||
|
||
func (s *Store) RequireFileWrite(ctx context.Context, userID, fileID uuid.UUID) (Access, error) {
|
||
a, err := s.ResolveFile(ctx, userID, fileID)
|
||
if err != nil {
|
||
return a, err
|
||
}
|
||
if !a.CanWrite() {
|
||
return a, ErrReadOnly
|
||
}
|
||
return a, nil
|
||
}
|
||
|
||
// IsGroupMember ist die öffentliche Fassung von isGroupMember – gebraucht von
|
||
// internal/tags, um zu prüfen, ob jemand einer Gruppe ein Etikett anlegen darf.
|
||
func (s *Store) IsGroupMember(ctx context.Context, userID, groupID uuid.UUID) (bool, error) {
|
||
return s.isGroupMember(ctx, userID, groupID)
|
||
}
|
||
|
||
func (s *Store) isGroupMember(ctx context.Context, userID, groupID uuid.UUID) (bool, error) {
|
||
var exists bool
|
||
err := s.db.QueryRow(ctx,
|
||
`SELECT EXISTS (SELECT 1 FROM group_members WHERE user_id = $1 AND group_id = $2)`,
|
||
userID, groupID).Scan(&exists)
|
||
if err != nil {
|
||
return false, fmt.Errorf("check group membership: %w", err)
|
||
}
|
||
return exists, nil
|
||
}
|
||
|
||
// bestPermissionForFolder läuft vom Ordner aus nach oben und liefert das
|
||
// stärkste Recht, das userID auf dem Weg findet – direkt oder über eine Gruppe.
|
||
// Leerer Rückgabewert heißt: keinerlei Zugriff.
|
||
func (s *Store) bestPermissionForFolder(ctx context.Context, userID, folderID uuid.UUID) (Permission, error) {
|
||
// Rekursiv nach oben statt nach unten: Der Pfad zur Wurzel ist ein paar
|
||
// Ebenen lang, der Unterbaum kann Tausende Einträge haben.
|
||
const q = `
|
||
WITH RECURSIVE ancestry AS (
|
||
SELECT id, parent_id FROM folders WHERE id = $1
|
||
UNION ALL
|
||
SELECT f.id, f.parent_id
|
||
FROM folders f
|
||
JOIN ancestry a ON f.id = a.parent_id
|
||
)
|
||
SELECT e.permission
|
||
FROM acl_entries e
|
||
JOIN ancestry a ON a.id = e.resource_folder_id
|
||
WHERE e.subject_user_id = $2
|
||
OR e.subject_group_id IN (
|
||
SELECT group_id FROM group_members WHERE user_id = $2
|
||
)`
|
||
|
||
rows, err := s.db.Query(ctx, q, folderID, userID)
|
||
if err != nil {
|
||
return "", fmt.Errorf("resolve folder permission: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
var best Permission
|
||
for rows.Next() {
|
||
var p Permission
|
||
if err := rows.Scan(&p); err != nil {
|
||
return "", fmt.Errorf("scan permission: %w", err)
|
||
}
|
||
best = stronger(best, p)
|
||
}
|
||
return best, rows.Err()
|
||
}
|
||
|
||
func (s *Store) directFilePermission(ctx context.Context, userID, fileID uuid.UUID) (Permission, error) {
|
||
rows, err := s.db.Query(ctx,
|
||
`SELECT permission FROM acl_entries
|
||
WHERE resource_file_id = $1
|
||
AND (subject_user_id = $2
|
||
OR subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $2))`,
|
||
fileID, userID)
|
||
if err != nil {
|
||
return "", fmt.Errorf("resolve file permission: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
var best Permission
|
||
for rows.Next() {
|
||
var p Permission
|
||
if err := rows.Scan(&p); err != nil {
|
||
return "", fmt.Errorf("scan permission: %w", err)
|
||
}
|
||
best = stronger(best, p)
|
||
}
|
||
return best, rows.Err()
|
||
}
|
||
|
||
// stronger: Mehrere Wege zur selben Ressource (direkt und über zwei Gruppen)
|
||
// sind normal. Das großzügigste Recht gilt – alles andere wäre für den Nutzer
|
||
// nicht nachvollziehbar.
|
||
func stronger(a, b Permission) Permission {
|
||
if a == PermWrite || b == PermWrite {
|
||
return PermWrite
|
||
}
|
||
if a == PermRead || b == PermRead {
|
||
return PermRead
|
||
}
|
||
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()
|
||
}
|