Get a quote

تأمين واجهات برمجة التطبيقات في أنظمة SaaS: المصادقة وإدارة الصلاحيات

المصادقة وإدارة الصلاحيات في أنظمة SaaS ليست مجرد إضافة JWT وانتهى الأمر. هذا المقال يشرح كيفية بناء نظام صلاحيات حقيقي بالأدوار والصلاحيات الدقيقة في Go مع PostgreSQL، ويتناول الأخطاء الشائعة التي تفتح ثغرات أمنية.

JWT يحل مشكلة التعرف على الهوية، لكنه لا يحل مشكلة الصلاحيات. في SaaS متعدد المستأجرين، الفجوة بين المصادقة والتفويض هي المكان الذي تعيش فيه أخطر أخطاء التحكم في الوصول. هذه هي بنية RBAC التي نبنيها في أنظمة SaaS الإنتاجية في Go.

لماذا المصادقة بالرمز وحدها تفشل في SaaS متعدد المستأجرين؟

الاختصار الأكثر شيوعاً: تضمين دور المستخدم كادعاء نصي في JWT ثم التحقق منه في المعالج. لهذا النهج فشلان أساسيان.

أولاً: ادعاءات الأدوار في JWT تصبح قديمة. مستخدم تغير دوره من مسؤول إلى مشاهد ما زال يحمل JWT مسؤول حتى تنتهي صلاحيته. في أنظمة بعمر JWT لمدة 24 ساعة، يحتفظ المستخدم بصلاحية المسؤول حتى 24 ساعة بعد تخفيض درجته.

ثانياً: دور مسؤول وحيد يمنح جميع الصلاحيات يصبح غير قابل للإدارة. المحاسب يحتاج قراءة التقارير المالية لكن لا يحذف المنتجات. مدير المتجر يحتاج إدارة المنتجات لكن لا يصل إلى كشف الرواتب.

نموذج بيانات RBAC

خزّن الأدوار والصلاحيات في PostgreSQL، وليس في ثوابت مبرمجة أو ادعاءات JWT:

CREATE TABLE roles (
  id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  name      TEXT NOT NULL,
  UNIQUE(tenant_id, name)
);

CREATE TABLE permissions (
  id   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  code TEXT NOT NULL UNIQUE
);

CREATE TABLE role_permissions (
  role_id       UUID NOT NULL REFERENCES roles(id),
  permission_id UUID NOT NULL REFERENCES permissions(id),
  PRIMARY KEY(role_id, permission_id)
);

CREATE TABLE user_roles (
  user_id   UUID NOT NULL,
  role_id   UUID NOT NULL,
  tenant_id UUID NOT NULL,
  PRIMARY KEY(user_id, role_id, tenant_id)
);

رموز الصلاحيات ثوابت نصية في كود Go (مثل orders:create، reports:read، products:delete). الأدوار مجموعات مسماة من الصلاحيات لكل مستأجر. يسمح هذا النموذج لمسؤولي المستأجرين بإنشاء أدوار مخصصة عبر واجهة مستخدم بدون نشر كود.

تحميل الصلاحيات بكفاءة

JWT يحمل فقط user_id وtenant_id. بعد التحقق، يحمّل الـ middleware الصلاحيات الفعلية من Redis مع PostgreSQL كمصدر حقيقة عند فقدان الـ cache:

func (s *AuthService) LoadPermissions(ctx context.Context, userID, tenantID uuid.UUID) (*UserPermissions, error) {
    cacheKey := fmt.Sprintf("perms:%s:%s", tenantID, userID)

    if cached, err := s.redis.Get(ctx, cacheKey).Result(); err == nil {
        var perms UserPermissions
        if json.Unmarshal([]byte(cached), &perms) == nil {
            return &perms, nil
        }
    }

    rows, err := s.db.Query(ctx, `
        SELECT DISTINCT p.code
        FROM user_roles ur
        JOIN role_permissions rp ON ur.role_id = rp.role_id
        JOIN permissions p ON rp.permission_id = p.id
        WHERE ur.user_id = $1 AND ur.tenant_id = $2
    `, userID, tenantID)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    perms := &UserPermissions{UserID: userID, TenantID: tenantID, PermCodes: make(map[string]bool)}
    for rows.Next() {
        var code string
        rows.Scan(&code)
        perms.PermCodes[code] = true
    }

    if data, err := json.Marshal(perms); err == nil {
        s.redis.SetEx(ctx, cacheKey, data, 5*time.Minute)
    }
    return perms, nil
}

TTL الـ cache لمدة 5 دقائق يوازن بين حمل قاعدة البيانات وحداثة الصلاحيات.

التحقق من الصلاحيات في Middleware

حقن الصلاحيات المحمّلة في context الطلب. middleware على مستوى المسار يتحقق من رمز صلاحية محدد:

func RequirePermission(code string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            perms, ok := PermissionsFromContext(r.Context())
            if !ok || !perms.Has(code) {
                http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

التطبيق عند تسجيل المسارات:

r.With(RequirePermission("products:delete")).Delete("/products/{id}", h.DeleteProduct)
r.With(RequirePermission("reports:read")).Get("/reports/revenue", h.GetRevenueReport)

الإلغاء الفوري للصلاحيات

عند إزالة دور من مستخدم، احذف مدخل cache الصلاحيات فوراً بعد إتمام المعاملة:

func (s *AuthService) UpdateUserRoles(ctx context.Context, userID, tenantID uuid.UUID, newRoles []uuid.UUID) error {
    err := s.db.WithTransaction(ctx, func(tx pgx.Tx) error {
        tx.Exec(ctx, "DELETE FROM user_roles WHERE user_id = $1 AND tenant_id = $2", userID, tenantID)
        for _, roleID := range newRoles {
            tx.Exec(ctx, "INSERT INTO user_roles VALUES ($1, $2, $3)", userID, roleID, tenantID)
        }
        return nil
    })
    if err == nil {
        s.redis.Del(ctx, fmt.Sprintf("perms:%s:%s", tenantID, userID))
    }
    return err
}

الأخطاء الشائعة التي تفتح ثغرات التحكم في الوصول

التحقق من الصلاحيات على طبقة HTTP فقط: إذا استدعيت طريقة الخدمة من وظيفة خلفية تتجاوز الـ middleware، لا يطبق التحقق أبداً. تحقق من الصلاحيات في طبقة الخدمة أيضاً.

استخدام tenant_id من جسم الطلب: عميل ضار يمكنه إرسال tenant_id مختلف. دائماً اشتق tenant_id من context JWT المتحقق منه.

الدروس الأساسية من الإنتاج

JWT آلية هوية وليس نظام تفويض. خزّن الأدوار والصلاحيات في PostgreSQL. خزّن مجموعة الصلاحيات في Redis مع TTL قصيرة. أخلِ فوراً مدخلات cache عند تغيير الأدوار. تحقق من الصلاحيات في طبقة الخدمة وليس فقط في طبقة توجيه HTTP. سجّل جميع رفض الصلاحيات وتعديلات الأدوار في سجل تدقيق منظم.

العودة إلى المدونة
Chat on WhatsApp