fix(PROJ-9): Superadmin-Tenant-NULL, GET admin/labels, from_domain-Allowlist
- labelTenantID() returns *int64 (nil for superadmin) statt int64(0) → verhindert FK-Constraint-Fehler bei tenant_id = 0 - CreateAdminLabel/CreateLabelRule: nil-Check, 400 wenn kein Tenant - GET /api/admin/labels Route + handleGetAdminLabels Handler ergänzt - from_domain in condition_field Allowlist für Label-Regeln hinzugefügt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ func (s *Server) SetLabels(store *labelstore.Store) {
|
||||
s.mux.HandleFunc("DELETE /api/mails/{id}/labels/{label_id}", s.auth(s.handleRemoveLabel))
|
||||
|
||||
// Admin label routes (domain_admin and above)
|
||||
s.mux.HandleFunc("GET /api/admin/labels", s.authAdmin(s.handleGetAdminLabels))
|
||||
s.mux.HandleFunc("POST /api/admin/labels", s.authAdmin(s.handleAdminCreateLabel))
|
||||
s.mux.HandleFunc("GET /api/admin/label-rules", s.authAdmin(s.handleGetLabelRules))
|
||||
s.mux.HandleFunc("POST /api/admin/label-rules", s.authAdmin(s.handleCreateLabelRule))
|
||||
@@ -46,7 +47,7 @@ func (s *Server) handleGetLabels(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := s.labelTenantID(sess)
|
||||
tenantID := s.labelTenantIDInt64(sess)
|
||||
labels, err := s.labels.GetLabelsForUser(r.Context(), sess.UserID, tenantID)
|
||||
if err != nil {
|
||||
s.logger.Error("get labels failed", "err", err, "user_id", sess.UserID)
|
||||
@@ -92,9 +93,13 @@ func (s *Server) handleCreateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := s.labelTenantID(sess)
|
||||
tenantIDPtr := s.labelTenantID(sess)
|
||||
if tenantIDPtr == nil {
|
||||
writeError(w, http.StatusBadRequest, "superadmin cannot create personal labels without a tenant")
|
||||
return
|
||||
}
|
||||
ownerID := sess.UserID
|
||||
label, err := s.labels.CreateLabel(r.Context(), req.Name, req.Color, &ownerID, tenantID)
|
||||
label, err := s.labels.CreateLabel(r.Context(), req.Name, req.Color, &ownerID, *tenantIDPtr)
|
||||
if err != nil {
|
||||
s.logger.Error("create label failed", "err", err, "user_id", sess.UserID)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create label")
|
||||
@@ -253,6 +258,29 @@ func (s *Server) handleRemoveLabel(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ── Admin Label Handlers ──────────────────────────────────────────────────
|
||||
|
||||
// handleGetAdminLabels returns all global labels (owner_id IS NULL) for the admin's tenant.
|
||||
// Superadmins (no tenant) see labels where tenant_id IS NULL.
|
||||
// GET /api/admin/labels
|
||||
func (s *Server) handleGetAdminLabels(w http.ResponseWriter, r *http.Request) {
|
||||
if s.labels == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "labels not available")
|
||||
return
|
||||
}
|
||||
sess := sessionFromCtx(r.Context())
|
||||
tenantID := s.labelTenantID(sess)
|
||||
|
||||
labels, err := s.labels.GetAdminLabels(r.Context(), tenantID)
|
||||
if err != nil {
|
||||
s.logger.Error("get admin labels failed", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to load labels")
|
||||
return
|
||||
}
|
||||
if labels == nil {
|
||||
labels = []labelstore.Label{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, labels)
|
||||
}
|
||||
|
||||
// handleAdminCreateLabel creates a global label (no owner) for a tenant.
|
||||
// POST /api/admin/labels
|
||||
func (s *Server) handleAdminCreateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -300,7 +328,7 @@ func (s *Server) handleGetLabelRules(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
sess := sessionFromCtx(r.Context())
|
||||
tenantID := s.labelTenantID(sess)
|
||||
tenantID := s.labelTenantIDInt64(sess)
|
||||
|
||||
rules, err := s.labels.GetLabelRules(r.Context(), tenantID)
|
||||
if err != nil {
|
||||
@@ -340,15 +368,19 @@ func (s *Server) handleCreateLabelRule(w http.ResponseWriter, r *http.Request) {
|
||||
// Validate allowed condition fields.
|
||||
allowed := map[string]bool{
|
||||
"from": true, "to": true, "subject": true, "domain": true,
|
||||
"has_attachment": true,
|
||||
"from_domain": true, "has_attachment": true,
|
||||
}
|
||||
if !allowed[req.ConditionField] {
|
||||
writeError(w, http.StatusBadRequest, "invalid condition_field")
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := s.labelTenantID(sess)
|
||||
rule, err := s.labels.CreateLabelRule(r.Context(), req.ConditionField, req.ConditionValue, req.LabelID, tenantID)
|
||||
tenantIDPtr := s.labelTenantID(sess)
|
||||
if tenantIDPtr == nil {
|
||||
writeError(w, http.StatusBadRequest, "superadmin cannot create label rules without a tenant")
|
||||
return
|
||||
}
|
||||
rule, err := s.labels.CreateLabelRule(r.Context(), req.ConditionField, req.ConditionValue, req.LabelID, *tenantIDPtr)
|
||||
if err != nil {
|
||||
s.logger.Error("create label rule failed", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create label rule")
|
||||
@@ -372,7 +404,7 @@ func (s *Server) handleDeleteLabelRule(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := s.labelTenantID(sess)
|
||||
tenantID := s.labelTenantIDInt64(sess)
|
||||
if err := s.labels.DeleteLabelRule(r.Context(), ruleID, tenantID); err != nil {
|
||||
s.logger.Error("delete label rule failed", "err", err, "rule_id", ruleID)
|
||||
writeError(w, http.StatusNotFound, "rule not found")
|
||||
@@ -384,8 +416,14 @@ func (s *Server) handleDeleteLabelRule(w http.ResponseWriter, r *http.Request) {
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
// labelTenantID extracts the tenant ID from the session for label operations.
|
||||
// Superadmins without a tenant get tenant_id=0 (global context).
|
||||
func (s *Server) labelTenantID(sess *auth.Session) int64 {
|
||||
// Returns nil for superadmins without a tenant (stored as NULL in DB).
|
||||
func (s *Server) labelTenantID(sess *auth.Session) *int64 {
|
||||
return sess.TenantID
|
||||
}
|
||||
|
||||
// labelTenantIDInt64 returns tenant_id as int64 (0 for superadmin with no tenant).
|
||||
// Only use for read queries where a zero result is acceptable (returns empty list).
|
||||
func (s *Server) labelTenantIDInt64(sess *auth.Session) int64 {
|
||||
if sess.TenantID != nil {
|
||||
return *sess.TenantID
|
||||
}
|
||||
|
||||
@@ -241,8 +241,56 @@ func (s *Store) GetEmailIDsByLabel(ctx context.Context, labelID int64) ([]string
|
||||
// ── Admin Labels (Global) ─────────────────────────────────────────────────
|
||||
|
||||
// CreateAdminLabel creates a global label (owner_id = NULL) for a tenant.
|
||||
func (s *Store) CreateAdminLabel(ctx context.Context, name, color string, tenantID int64) (Label, error) {
|
||||
return s.CreateLabel(ctx, name, color, nil, tenantID)
|
||||
// tenantID may be nil for superadmin-global labels (stored as NULL).
|
||||
func (s *Store) CreateAdminLabel(ctx context.Context, name, color string, tenantID *int64) (Label, error) {
|
||||
var l Label
|
||||
err := s.db.QueryRow(ctx, `
|
||||
INSERT INTO labels (name, color, owner_id, tenant_id)
|
||||
VALUES ($1, $2, NULL, $3)
|
||||
RETURNING id, name, color, owner_id, tenant_id, created_at
|
||||
`, name, color, tenantID).Scan(&l.ID, &l.Name, &l.Color, &l.OwnerID, &l.TenantID, &l.CreatedAt)
|
||||
if err != nil {
|
||||
return Label{}, fmt.Errorf("labelstore: create admin label: %w", err)
|
||||
}
|
||||
l.IsGlobal = true
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// GetAdminLabels returns all global labels (owner_id IS NULL) for superadmin
|
||||
// (tenantID == nil) or for a specific tenant.
|
||||
func (s *Store) GetAdminLabels(ctx context.Context, tenantID *int64) ([]Label, error) {
|
||||
var rows interface{ Next() bool; Scan(...any) error; Close(); Err() error }
|
||||
var err error
|
||||
if tenantID == nil {
|
||||
rows, err = s.db.Query(ctx, `
|
||||
SELECT id, name, color, owner_id, tenant_id, created_at
|
||||
FROM labels
|
||||
WHERE owner_id IS NULL AND tenant_id IS NULL
|
||||
ORDER BY name
|
||||
`)
|
||||
} else {
|
||||
rows, err = s.db.Query(ctx, `
|
||||
SELECT id, name, color, owner_id, tenant_id, created_at
|
||||
FROM labels
|
||||
WHERE owner_id IS NULL AND tenant_id = $1
|
||||
ORDER BY name
|
||||
`, *tenantID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("labelstore: get admin labels: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var labels []Label
|
||||
for rows.Next() {
|
||||
var l Label
|
||||
if err := rows.Scan(&l.ID, &l.Name, &l.Color, &l.OwnerID, &l.TenantID, &l.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("labelstore: scan admin label: %w", err)
|
||||
}
|
||||
l.IsGlobal = true
|
||||
labels = append(labels, l)
|
||||
}
|
||||
return labels, rows.Err()
|
||||
}
|
||||
|
||||
// ── Label Rules ───────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user