Files
Aether/backend/internal/acl/acl.go
T
HoneyandClaude Opus 5 31a027c93a feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:25:36 +02:00

352 lines
11 KiB
Go
Raw 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 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, &quota)
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, &quota, &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 ""
}