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>
321 lines
9.8 KiB
Go
321 lines
9.8 KiB
Go
package acl
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
"github.com/google/uuid"
|
||
|
||
"aether/internal/audit"
|
||
"aether/internal/auth"
|
||
"aether/pkg/middleware"
|
||
"aether/pkg/response"
|
||
)
|
||
|
||
// Handler bedient den Freigabedialog. Die Zugriffsprüfung selbst passiert im
|
||
// files-Paket; hier geht es nur ums Erteilen, Ansehen und Widerrufen.
|
||
//
|
||
// Freigeben darf ausschließlich der Eigentümer. Wer selbst nur eine
|
||
// Schreibfreigabe hat, kann sie nicht weiterreichen – sonst verlöre der
|
||
// Eigentümer die Kontrolle darüber, wer seine Daten sieht.
|
||
type Handler struct {
|
||
store *Store
|
||
auditor *audit.Logger
|
||
}
|
||
|
||
func NewHandler(store *Store, auditor *audit.Logger) *Handler {
|
||
return &Handler{store: store, auditor: auditor}
|
||
}
|
||
|
||
// logShare hält fest, wer wem Zugriff auf was gegeben oder genommen hat. Bei
|
||
// Freigaben ist das die interessanteste Spur überhaupt: Sie erklärt, wie jemand
|
||
// an Daten kam, die ihm nicht gehören.
|
||
func (h *Handler) logShare(r *http.Request, action, resourceType, resourceID, detail string) {
|
||
user := auth.UserFromContext(r.Context())
|
||
e := audit.Event{
|
||
Action: action,
|
||
TargetType: resourceType,
|
||
TargetID: resourceID,
|
||
Detail: detail,
|
||
IP: middleware.RealIP(r),
|
||
}
|
||
if user != nil {
|
||
e.ActorID = &user.ID
|
||
e.ActorName = user.Username
|
||
}
|
||
h.auditor.Log(r.Context(), e)
|
||
}
|
||
|
||
// ownedFolder / ownedFile liefern die Ressourcen-ID und den Anfragenden –
|
||
// aber nur, wenn ihm die Ressource auch gehört. Alles andere ist bereits
|
||
// beantwortet.
|
||
func (h *Handler) ownedFolder(w http.ResponseWriter, r *http.Request) (uuid.UUID, uuid.UUID, bool) {
|
||
user := auth.UserFromContext(r.Context())
|
||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||
if err != nil {
|
||
response.Error(w, http.StatusBadRequest, "invalid folder id")
|
||
return uuid.Nil, uuid.Nil, false
|
||
}
|
||
a, err := h.store.ResolveFolder(r.Context(), user.ID, id)
|
||
if err != nil {
|
||
response.Error(w, http.StatusNotFound, "folder not found")
|
||
return uuid.Nil, uuid.Nil, false
|
||
}
|
||
if a.OwnerID != user.ID {
|
||
response.Error(w, http.StatusForbidden, "only the owner can manage sharing")
|
||
return uuid.Nil, uuid.Nil, false
|
||
}
|
||
return id, user.ID, true
|
||
}
|
||
|
||
func (h *Handler) ownedFile(w http.ResponseWriter, r *http.Request) (uuid.UUID, uuid.UUID, bool) {
|
||
user := auth.UserFromContext(r.Context())
|
||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||
if err != nil {
|
||
response.Error(w, http.StatusBadRequest, "invalid file id")
|
||
return uuid.Nil, uuid.Nil, false
|
||
}
|
||
a, err := h.store.ResolveFile(r.Context(), user.ID, id)
|
||
if err != nil {
|
||
response.Error(w, http.StatusNotFound, "file not found")
|
||
return uuid.Nil, uuid.Nil, false
|
||
}
|
||
if a.OwnerID != user.ID {
|
||
response.Error(w, http.StatusForbidden, "only the owner can manage sharing")
|
||
return uuid.Nil, uuid.Nil, false
|
||
}
|
||
return id, user.ID, true
|
||
}
|
||
|
||
// ownedCalendar: Nur der Eigentümer verwaltet die Freigaben seines Kalenders.
|
||
// Wer ihn freigegeben *bekommen* hat – auch schreibend –, darf ihn nicht
|
||
// weiterreichen. Ein Recht weiterzugeben, das man selbst nur geliehen hat,
|
||
// ist etwas anderes als es zu benutzen.
|
||
func (h *Handler) ownedCalendar(w http.ResponseWriter, r *http.Request) (uuid.UUID, uuid.UUID, bool) {
|
||
user := auth.UserFromContext(r.Context())
|
||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||
if err != nil {
|
||
response.Error(w, http.StatusBadRequest, "invalid calendar id")
|
||
return uuid.Nil, uuid.Nil, false
|
||
}
|
||
owner, err := h.store.CalendarOwner(r.Context(), id)
|
||
if err != nil {
|
||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||
return uuid.Nil, uuid.Nil, false
|
||
}
|
||
if owner != user.ID {
|
||
// 404 statt 403: Wer den Kalender nicht besitzt, soll aus der Antwort
|
||
// nicht erfahren, dass es ihn gibt.
|
||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||
return uuid.Nil, uuid.Nil, false
|
||
}
|
||
return id, user.ID, true
|
||
}
|
||
|
||
// GET /api/v1/calendars/{id}/acl
|
||
func (h *Handler) ListCalendarACL(w http.ResponseWriter, r *http.Request) {
|
||
id, _, ok := h.ownedCalendar(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
entries, err := h.store.ListForCalendar(r.Context(), id)
|
||
if err != nil {
|
||
response.Error(w, http.StatusInternalServerError, "could not load shares")
|
||
return
|
||
}
|
||
response.JSON(w, http.StatusOK, entries)
|
||
}
|
||
|
||
// POST /api/v1/calendars/{id}/acl
|
||
func (h *Handler) GrantCalendarACL(w http.ResponseWriter, r *http.Request) {
|
||
id, owner, ok := h.ownedCalendar(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
req, ok := decodeGrant(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
entry, err := h.store.GrantCalendar(r.Context(), id, req.subjectID, req.isGroup, req.perm, owner)
|
||
if err == nil {
|
||
h.logShare(r, audit.ActionShareGrant, "calendar", id.String(),
|
||
string(req.perm)+" for "+req.subjectType()+" "+req.subjectID.String())
|
||
}
|
||
writeGrantResult(w, entry, err)
|
||
}
|
||
|
||
func userFrom(r *http.Request) uuid.UUID {
|
||
if u := auth.UserFromContext(r.Context()); u != nil {
|
||
return u.ID
|
||
}
|
||
return uuid.Nil
|
||
}
|
||
|
||
type grantRequest struct {
|
||
SubjectID string `json:"subject_id"`
|
||
SubjectType string `json:"subject_type"` // "user" | "group"
|
||
Permission Permission `json:"permission"`
|
||
}
|
||
|
||
// GET /api/v1/folders/{id}/acl
|
||
func (h *Handler) ListFolderACL(w http.ResponseWriter, r *http.Request) {
|
||
id, _, ok := h.ownedFolder(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
entries, err := h.store.ListForFolder(r.Context(), id)
|
||
if err != nil {
|
||
response.Error(w, http.StatusInternalServerError, "could not load shares")
|
||
return
|
||
}
|
||
response.JSON(w, http.StatusOK, entries)
|
||
}
|
||
|
||
// POST /api/v1/folders/{id}/acl
|
||
func (h *Handler) GrantFolderACL(w http.ResponseWriter, r *http.Request) {
|
||
id, owner, ok := h.ownedFolder(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
req, ok := decodeGrant(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
entry, err := h.store.GrantFolder(r.Context(), id, req.subjectID, req.isGroup, req.perm, owner)
|
||
if err == nil {
|
||
h.logShare(r, audit.ActionShareGrant, "folder", id.String(),
|
||
string(req.perm)+" for "+req.subjectType()+" "+req.subjectID.String())
|
||
}
|
||
writeGrantResult(w, entry, err)
|
||
}
|
||
|
||
// GET /api/v1/files/{id}/acl
|
||
func (h *Handler) ListFileACL(w http.ResponseWriter, r *http.Request) {
|
||
id, _, ok := h.ownedFile(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
entries, err := h.store.ListForFile(r.Context(), id)
|
||
if err != nil {
|
||
response.Error(w, http.StatusInternalServerError, "could not load shares")
|
||
return
|
||
}
|
||
response.JSON(w, http.StatusOK, entries)
|
||
}
|
||
|
||
// POST /api/v1/files/{id}/acl
|
||
func (h *Handler) GrantFileACL(w http.ResponseWriter, r *http.Request) {
|
||
id, owner, ok := h.ownedFile(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
req, ok := decodeGrant(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
entry, err := h.store.GrantFile(r.Context(), id, req.subjectID, req.isGroup, req.perm, owner)
|
||
if err == nil {
|
||
h.logShare(r, audit.ActionShareGrant, "file", id.String(),
|
||
string(req.perm)+" for "+req.subjectType()+" "+req.subjectID.String())
|
||
}
|
||
writeGrantResult(w, entry, err)
|
||
}
|
||
|
||
// DELETE /api/v1/acl/{id}
|
||
func (h *Handler) RevokeACL(w http.ResponseWriter, r *http.Request) {
|
||
user := userFrom(r)
|
||
if user == uuid.Nil {
|
||
response.Error(w, http.StatusUnauthorized, "unauthorized")
|
||
return
|
||
}
|
||
entryID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||
if err != nil {
|
||
response.Error(w, http.StatusBadRequest, "invalid id")
|
||
return
|
||
}
|
||
if err := h.store.Revoke(r.Context(), entryID, user); errors.Is(err, ErrNotFound) {
|
||
response.Error(w, http.StatusNotFound, "share not found")
|
||
return
|
||
} else if err != nil {
|
||
response.Error(w, http.StatusInternalServerError, "could not revoke share")
|
||
return
|
||
}
|
||
h.logShare(r, audit.ActionShareRevoke, "acl", entryID.String(), "")
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// GET /api/v1/shared-with-me
|
||
func (h *Handler) SharedWithMe(w http.ResponseWriter, r *http.Request) {
|
||
user := userFrom(r)
|
||
if user == uuid.Nil {
|
||
response.Error(w, http.StatusUnauthorized, "unauthorized")
|
||
return
|
||
}
|
||
items, err := h.store.ListSharedWith(r.Context(), user)
|
||
if err != nil {
|
||
response.Error(w, http.StatusInternalServerError, "could not load shared items")
|
||
return
|
||
}
|
||
response.JSON(w, http.StatusOK, items)
|
||
}
|
||
|
||
// GET /api/v1/share-recipients
|
||
func (h *Handler) Recipients(w http.ResponseWriter, r *http.Request) {
|
||
user := userFrom(r)
|
||
if user == uuid.Nil {
|
||
response.Error(w, http.StatusUnauthorized, "unauthorized")
|
||
return
|
||
}
|
||
list, err := h.store.Recipients(r.Context(), user)
|
||
if err != nil {
|
||
response.Error(w, http.StatusInternalServerError, "could not load recipients")
|
||
return
|
||
}
|
||
response.JSON(w, http.StatusOK, list)
|
||
}
|
||
|
||
// ── Hilfen ───────────────────────────────────────────────────────────────────
|
||
|
||
type parsedGrant struct {
|
||
subjectID uuid.UUID
|
||
isGroup bool
|
||
perm Permission
|
||
}
|
||
|
||
func (g parsedGrant) subjectType() string { return subjectTypeName(g.isGroup) }
|
||
|
||
func decodeGrant(w http.ResponseWriter, r *http.Request) (parsedGrant, bool) {
|
||
var body grantRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||
return parsedGrant{}, false
|
||
}
|
||
id, err := uuid.Parse(body.SubjectID)
|
||
if err != nil {
|
||
response.Error(w, http.StatusBadRequest, "invalid subject_id")
|
||
return parsedGrant{}, false
|
||
}
|
||
if body.SubjectType != "user" && body.SubjectType != "group" {
|
||
response.Error(w, http.StatusBadRequest, `subject_type must be "user" or "group"`)
|
||
return parsedGrant{}, false
|
||
}
|
||
if !body.Permission.valid() {
|
||
response.Error(w, http.StatusBadRequest, `permission must be "read" or "write"`)
|
||
return parsedGrant{}, false
|
||
}
|
||
return parsedGrant{subjectID: id, isGroup: body.SubjectType == "group", perm: body.Permission}, true
|
||
}
|
||
|
||
func writeGrantResult(w http.ResponseWriter, entry *Entry, err error) {
|
||
switch {
|
||
case errors.Is(err, ErrSelfShare):
|
||
response.Error(w, http.StatusBadRequest, "you already have access to this")
|
||
case err != nil:
|
||
response.Error(w, http.StatusInternalServerError, "could not share")
|
||
default:
|
||
response.JSON(w, http.StatusCreated, entry)
|
||
}
|
||
}
|