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>
324 lines
12 KiB
Go
324 lines
12 KiB
Go
package acl
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgconn"
|
||
)
|
||
|
||
var ErrSelfShare = errors.New("cannot share with yourself")
|
||
|
||
// Entry ist eine Freigabe, wie sie der Eigentümer im Dialog sieht.
|
||
type Entry struct {
|
||
ID uuid.UUID `json:"id"`
|
||
SubjectType string `json:"subject_type"` // "user" | "group"
|
||
SubjectID uuid.UUID `json:"subject_id"`
|
||
SubjectName string `json:"subject_name"`
|
||
Permission Permission `json:"permission"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
// SharedResource ist ein Eintrag in „Geteilt mit mir".
|
||
type SharedResource struct {
|
||
Type string `json:"type"` // "folder" | "file"
|
||
ID uuid.UUID `json:"id"`
|
||
Name string `json:"name"`
|
||
OwnerID uuid.UUID `json:"owner_id"`
|
||
OwnerName string `json:"owner_name"`
|
||
Permission Permission `json:"permission"`
|
||
Size int64 `json:"size"`
|
||
MimeType string `json:"mime_type"`
|
||
SharedAt time.Time `json:"shared_at"`
|
||
// ViaGroup nennt die Gruppe, über die der Zugriff kommt – sonst wundert
|
||
// man sich, wo die Freigabe herkommt, die man nie persönlich bekommen hat.
|
||
ViaGroup string `json:"via_group"`
|
||
}
|
||
|
||
// ── 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, 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, resource{file: &fileID}, subjectID, subjectIsGroup, perm, grantedBy)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
// Sich selbst freigeben ist sinnlos und würde in „Geteilt mit mir" die
|
||
// eigenen Ordner doppelt anzeigen.
|
||
if !subjectIsGroup && subjectID == grantedBy {
|
||
return nil, ErrSelfShare
|
||
}
|
||
|
||
var subjUser, subjGroup *uuid.UUID
|
||
if subjectIsGroup {
|
||
subjGroup = &subjectID
|
||
} else {
|
||
subjUser = &subjectID
|
||
}
|
||
|
||
var e Entry
|
||
err := s.db.QueryRow(ctx,
|
||
`INSERT INTO acl_entries
|
||
(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, 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
|
||
// Änderung des Rechts – sonst müsste man erst widerrufen, um von „lesen"
|
||
// auf „schreiben" zu wechseln. Die vier partiellen Unique-Indizes lassen
|
||
// sich nicht in einer einzigen ON-CONFLICT-Klausel abdecken, deshalb hier
|
||
// über den Fehlercode.
|
||
var pgErr *pgconn.PgError
|
||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||
return s.updateExisting(ctx, res, subjUser, subjGroup, perm)
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("grant access: %w", err)
|
||
}
|
||
|
||
e.SubjectID = subjectID
|
||
e.SubjectType = subjectTypeName(subjectIsGroup)
|
||
return &e, nil
|
||
}
|
||
|
||
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 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, res.folder, res.file, res.calendar, subjUser, subjGroup,
|
||
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("update access: %w", err)
|
||
}
|
||
if subjGroup != nil {
|
||
e.SubjectID, e.SubjectType = *subjGroup, "group"
|
||
} else if subjUser != nil {
|
||
e.SubjectID, e.SubjectType = *subjUser, "user"
|
||
}
|
||
return &e, nil
|
||
}
|
||
|
||
// Revoke entfernt eine Freigabe. ownerID stellt sicher, dass nur der
|
||
// Eigentümer der Ressource widerrufen kann.
|
||
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)
|
||
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)
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ListForFolder / ListForFile zeigen dem Eigentümer, wer aktuell Zugriff hat.
|
||
func (s *Store) ListForFolder(ctx context.Context, folderID uuid.UUID) ([]Entry, error) {
|
||
return s.list(ctx, `e.resource_folder_id = $1`, folderID)
|
||
}
|
||
|
||
func (s *Store) ListForFile(ctx context.Context, fileID uuid.UUID) ([]Entry, error) {
|
||
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,
|
||
CASE WHEN e.subject_group_id IS NOT NULL THEN 'group' ELSE 'user' END,
|
||
COALESCE(e.subject_group_id, e.subject_user_id),
|
||
COALESCE(g.name, NULLIF(u.display_name, ''), u.username, ''),
|
||
e.permission, e.created_at
|
||
FROM acl_entries e
|
||
LEFT JOIN users u ON u.id = e.subject_user_id
|
||
LEFT JOIN groups g ON g.id = e.subject_group_id
|
||
WHERE `+where+`
|
||
ORDER BY e.created_at`, resourceID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list access entries: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := []Entry{}
|
||
for rows.Next() {
|
||
var e Entry
|
||
if err := rows.Scan(&e.ID, &e.SubjectType, &e.SubjectID, &e.SubjectName,
|
||
&e.Permission, &e.CreatedAt); err != nil {
|
||
return nil, fmt.Errorf("scan access entry: %w", err)
|
||
}
|
||
out = append(out, e)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// ListSharedWith liefert alles, was userID freigegeben bekommen hat – direkt
|
||
// oder über eine Gruppe. Das ist der Inhalt von „Geteilt mit mir".
|
||
//
|
||
// Nur die obersten freigegebenen Knoten erscheinen: Wer einen Ordner und darin
|
||
// eine Datei freigegeben bekommt, soll den Ordner sehen und die Datei darin,
|
||
// nicht beides nebeneinander.
|
||
func (s *Store) ListSharedWith(ctx context.Context, userID uuid.UUID) ([]SharedResource, error) {
|
||
const q = `
|
||
SELECT 'folder', f.id, f.name, f.owner_id,
|
||
COALESCE(NULLIF(o.display_name, ''), o.username),
|
||
e.permission, 0::bigint, '', e.created_at,
|
||
COALESCE(g.name, '')
|
||
FROM acl_entries e
|
||
JOIN folders f ON f.id = e.resource_folder_id
|
||
JOIN users o ON o.id = f.owner_id
|
||
LEFT JOIN groups g ON g.id = e.subject_group_id
|
||
WHERE (e.subject_user_id = $1
|
||
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
|
||
-- Ordner weglassen, deren Elternteil ohnehin schon freigegeben ist.
|
||
AND NOT EXISTS (
|
||
SELECT 1
|
||
FROM acl_entries pe
|
||
JOIN folders pf ON pf.id = pe.resource_folder_id
|
||
WHERE pf.id = f.parent_id
|
||
AND (pe.subject_user_id = $1
|
||
OR pe.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
|
||
)
|
||
|
||
UNION ALL
|
||
|
||
SELECT 'file', x.id, x.name, x.owner_id,
|
||
COALESCE(NULLIF(o.display_name, ''), o.username),
|
||
e.permission, x.size, x.mime_type, e.created_at,
|
||
COALESCE(g.name, '')
|
||
FROM acl_entries e
|
||
JOIN files x ON x.id = e.resource_file_id
|
||
JOIN users o ON o.id = x.owner_id
|
||
LEFT JOIN groups g ON g.id = e.subject_group_id
|
||
WHERE x.is_deleted = false
|
||
AND (e.subject_user_id = $1
|
||
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
|
||
-- Datei weglassen, wenn ihr Ordner bereits freigegeben ist.
|
||
AND NOT EXISTS (
|
||
SELECT 1
|
||
FROM acl_entries fe
|
||
WHERE fe.resource_folder_id = x.folder_id
|
||
AND (fe.subject_user_id = $1
|
||
OR fe.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
|
||
)
|
||
|
||
ORDER BY 1, 3`
|
||
|
||
rows, err := s.db.Query(ctx, q, userID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list shared resources: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := []SharedResource{}
|
||
for rows.Next() {
|
||
var r SharedResource
|
||
if err := rows.Scan(&r.Type, &r.ID, &r.Name, &r.OwnerID, &r.OwnerName,
|
||
&r.Permission, &r.Size, &r.MimeType, &r.SharedAt, &r.ViaGroup); err != nil {
|
||
return nil, fmt.Errorf("scan shared resource: %w", err)
|
||
}
|
||
out = append(out, r)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// ── Verzeichnis für den Freigabedialog ───────────────────────────────────────
|
||
|
||
type Recipient struct {
|
||
ID uuid.UUID `json:"id"`
|
||
Name string `json:"name"`
|
||
Type string `json:"type"` // "user" | "group"
|
||
}
|
||
|
||
// Recipients listet mögliche Empfänger. Bewusst nur ID, Anzeigename und Art –
|
||
// Quota, Admin-Status und E-Mail gehen einen normalen Nutzer nichts an, auch
|
||
// wenn er teilen darf.
|
||
func (s *Store) Recipients(ctx context.Context, exclude uuid.UUID) ([]Recipient, error) {
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT id, COALESCE(NULLIF(display_name, ''), username), 'user'
|
||
FROM users WHERE id <> $1
|
||
UNION ALL
|
||
SELECT id, name, 'group' FROM groups
|
||
ORDER BY 3 DESC, 2`, exclude)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list recipients: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := []Recipient{}
|
||
for rows.Next() {
|
||
var r Recipient
|
||
if err := rows.Scan(&r.ID, &r.Name, &r.Type); err != nil {
|
||
return nil, fmt.Errorf("scan recipient: %w", err)
|
||
}
|
||
out = append(out, r)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func subjectTypeName(isGroup bool) string {
|
||
if isGroup {
|
||
return "group"
|
||
}
|
||
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
|
||
}
|