// 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() }