Browse Source

feat: implement GDPR-compliant 3-state data access policy #95

This commit implements a comprehensive 3-state data access policy system
for GDPR compliance, allowing store owners to control how their data is
accessed and stored.

Changes:

Database:
- Add data_access_policy enum type (sync, api_only, not_allowed)
- Add new columns to store_sync_config table
- Create helper functions for managing access policies
- Migrate existing boolean values to new enum columns
- Maintain backward compatibility with deprecated boolean columns

Backend API:
- Create access-policy-helpers.ts for centralized policy checking
- Update mcp-qdrant-helpers.ts to return access policy info
- Add PUT /api/stores/:id/access-policies endpoint
- Update GET /api/stores to include access policy fields
- Implement WooCommerce products handler with 3-state support:
  * sync: Use Qdrant/SQL cache for fast access
  * api_only: Direct API calls without caching (GDPR-friendly)
  * not_allowed: Block access with informative error

Frontend:
- Replace boolean switches with 3-state radio button groups
- Add visual indicators for each mode (Database, Cloud, Ban icons)
- Show privacy level badge (Maximum Privacy → Full Sync)
- Add informative alerts and GDPR compliance notices
- Update TypeScript interfaces for new policy system

Implementation follows a consistent pattern that can be easily replicated
across remaining MCP tool handlers (WooCommerce orders/customers, Shopify,
ShopRenter) and sync Edge Functions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude 5 months ago
parent
commit
6ece90b

+ 152 - 92
shopcall.ai-main/src/components/DataAccessSettings.tsx

@@ -4,38 +4,59 @@ import { Switch } from "@/components/ui/switch";
 import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import { Alert, AlertDescription } from "@/components/ui/alert";
-import { Shield, ShieldCheck, ShieldAlert, Info, Loader2 } from "lucide-react";
+import { Label } from "@/components/ui/label";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Shield, ShieldCheck, ShieldAlert, Info, Loader2, Database, Cloud, Ban } from "lucide-react";
 import { API_URL } from "@/lib/config";
 import { useToast } from "@/hooks/use-toast";
 
+type DataAccessPolicy = 'sync' | 'api_only' | 'not_allowed';
+
 interface DataAccessPermissions {
   allow_customer_access: boolean;
   allow_order_access: boolean;
   allow_product_access: boolean;
 }
 
+interface AccessPolicySettings {
+  products_access_policy: DataAccessPolicy;
+  customers_access_policy: DataAccessPolicy;
+  orders_access_policy: DataAccessPolicy;
+}
+
 interface DataAccessSettingsProps {
   storeId: string;
   storeName: string;
   currentPermissions: DataAccessPermissions;
+  currentPolicies?: AccessPolicySettings;
   onPermissionsUpdated?: (newPermissions: DataAccessPermissions) => void;
+  onPoliciesUpdated?: (newPolicies: AccessPolicySettings) => void;
 }
 
 export function DataAccessSettings({
   storeId,
   storeName,
   currentPermissions,
-  onPermissionsUpdated
+  currentPolicies,
+  onPermissionsUpdated,
+  onPoliciesUpdated
 }: DataAccessSettingsProps) {
-  const [permissions, setPermissions] = useState<DataAccessPermissions>(currentPermissions);
+  // Initialize with new policy-based system if available, otherwise convert from old permissions
+  const [policies, setPolicies] = useState<AccessPolicySettings>(
+    currentPolicies || {
+      products_access_policy: currentPermissions.allow_product_access ? 'sync' : 'not_allowed',
+      customers_access_policy: currentPermissions.allow_customer_access ? 'sync' : 'not_allowed',
+      orders_access_policy: currentPermissions.allow_order_access ? 'sync' : 'not_allowed'
+    }
+  );
   const [saving, setSaving] = useState(false);
   const [hasChanges, setHasChanges] = useState(false);
   const { toast } = useToast();
 
-  const handleToggle = (key: keyof DataAccessPermissions) => {
-    setPermissions(prev => ({
+  const handlePolicyChange = (dataType: 'products' | 'customers' | 'orders', policy: DataAccessPolicy) => {
+    setPolicies(prev => ({
       ...prev,
-      [key]: !prev[key]
+      [`${dataType}_access_policy`]: policy
     }));
     setHasChanges(true);
   };
@@ -49,37 +70,35 @@ export function DataAccessSettings({
       }
 
       const session = JSON.parse(sessionData);
-      const response = await fetch(`${API_URL}/api/stores/${storeId}/permissions`, {
+      const response = await fetch(`${API_URL}/api/stores/${storeId}/access-policies`, {
         method: 'PUT',
         headers: {
           'Authorization': `Bearer ${session.session.access_token}`,
           'Content-Type': 'application/json'
         },
-        body: JSON.stringify({
-          data_access_permissions: permissions
-        })
+        body: JSON.stringify(policies)
       });
 
       const data = await response.json();
 
       if (!response.ok) {
-        throw new Error(data.error || 'Failed to update permissions');
+        throw new Error(data.error || 'Failed to update access policies');
       }
 
       toast({
-        title: "Permissions Updated",
-        description: "Data access permissions have been successfully updated.",
+        title: "Access Policies Updated",
+        description: "Data access policies have been successfully updated.",
       });
 
       setHasChanges(false);
-      if (onPermissionsUpdated) {
-        onPermissionsUpdated(permissions);
+      if (onPoliciesUpdated) {
+        onPoliciesUpdated(policies);
       }
     } catch (error) {
-      console.error('Error updating permissions:', error);
+      console.error('Error updating access policies:', error);
       toast({
         title: "Update Failed",
-        description: error instanceof Error ? error.message : "Failed to update permissions. Please try again.",
+        description: error instanceof Error ? error.message : "Failed to update access policies. Please try again.",
         variant: "destructive",
       });
     } finally {
@@ -88,20 +107,80 @@ export function DataAccessSettings({
   };
 
   const handleReset = () => {
-    setPermissions(currentPermissions);
+    setPolicies(currentPolicies || {
+      products_access_policy: currentPermissions.allow_product_access ? 'sync' : 'not_allowed',
+      customers_access_policy: currentPermissions.allow_customer_access ? 'sync' : 'not_allowed',
+      orders_access_policy: currentPermissions.allow_order_access ? 'sync' : 'not_allowed'
+    });
     setHasChanges(false);
   };
 
   const getSecurityLevel = () => {
-    const enabledCount = Object.values(permissions).filter(Boolean).length;
-    if (enabledCount === 0) return { level: "high", icon: ShieldCheck, color: "text-green-500", label: "High Privacy" };
-    if (enabledCount <= 1) return { level: "medium", icon: Shield, color: "text-yellow-500", label: "Medium Privacy" };
-    return { level: "low", icon: ShieldAlert, color: "text-orange-500", label: "Full Access" };
+    const notAllowedCount = Object.values(policies).filter(p => p === 'not_allowed').length;
+    const apiOnlyCount = Object.values(policies).filter(p => p === 'api_only').length;
+
+    if (notAllowedCount === 3) return { level: "maximum", icon: ShieldCheck, color: "text-green-500", label: "Maximum Privacy" };
+    if (notAllowedCount >= 2) return { level: "high", icon: ShieldCheck, color: "text-green-400", label: "High Privacy" };
+    if (notAllowedCount === 1 || apiOnlyCount >= 2) return { level: "medium", icon: Shield, color: "text-yellow-500", label: "Medium Privacy" };
+    if (apiOnlyCount >= 1) return { level: "balanced", icon: Shield, color: "text-blue-500", label: "Balanced" };
+    return { level: "full", icon: ShieldAlert, color: "text-orange-500", label: "Full Sync" };
   };
 
   const security = getSecurityLevel();
   const SecurityIcon = security.icon;
 
+  const renderPolicyOptions = (dataType: 'products' | 'customers' | 'orders', policyKey: keyof AccessPolicySettings, isPII: boolean = false) => {
+    return (
+      <RadioGroup
+        value={policies[policyKey]}
+        onValueChange={(value) => handlePolicyChange(dataType, value as DataAccessPolicy)}
+        className="space-y-3 mt-3"
+      >
+        <div className="flex items-start space-x-3 p-3 rounded-md bg-slate-600/30 hover:bg-slate-600/50 transition-colors">
+          <RadioGroupItem value="sync" id={`${dataType}-sync`} className="mt-1" disabled={saving} />
+          <Label htmlFor={`${dataType}-sync`} className="cursor-pointer flex-1">
+            <div className="flex items-center gap-2 mb-1">
+              <Database className="w-4 h-4 text-cyan-400" />
+              <span className="text-white font-medium">Sync & Cache</span>
+              <Badge variant="outline" className="text-xs border-cyan-500 text-cyan-400">Fastest</Badge>
+            </div>
+            <p className="text-xs text-slate-400">
+              Store data locally in database and Qdrant vector search for instant access
+            </p>
+          </Label>
+        </div>
+
+        <div className="flex items-start space-x-3 p-3 rounded-md bg-slate-600/30 hover:bg-slate-600/50 transition-colors">
+          <RadioGroupItem value="api_only" id={`${dataType}-api`} className="mt-1" disabled={saving} />
+          <Label htmlFor={`${dataType}-api`} className="cursor-pointer flex-1">
+            <div className="flex items-center gap-2 mb-1">
+              <Cloud className="w-4 h-4 text-blue-400" />
+              <span className="text-white font-medium">API Access Only</span>
+              {isPII && <Badge variant="outline" className="text-xs border-green-500 text-green-400">GDPR Friendly</Badge>}
+            </div>
+            <p className="text-xs text-slate-400">
+              Fetch data directly from your store's API on demand (no local storage)
+            </p>
+          </Label>
+        </div>
+
+        <div className="flex items-start space-x-3 p-3 rounded-md bg-slate-600/30 hover:bg-slate-600/50 transition-colors">
+          <RadioGroupItem value="not_allowed" id={`${dataType}-none`} className="mt-1" disabled={saving} />
+          <Label htmlFor={`${dataType}-none`} className="cursor-pointer flex-1">
+            <div className="flex items-center gap-2 mb-1">
+              <Ban className="w-4 h-4 text-red-400" />
+              <span className="text-white font-medium">No Access</span>
+              <Badge variant="outline" className="text-xs border-red-500 text-red-400">Blocked</Badge>
+            </div>
+            <p className="text-xs text-slate-400">
+              Completely block access to this data type
+            </p>
+          </Label>
+        </div>
+      </RadioGroup>
+    );
+  };
+
   return (
     <Card className="bg-slate-800 border-slate-700">
       <CardHeader>
@@ -125,82 +204,54 @@ export function DataAccessSettings({
         <Alert className="bg-blue-500/10 border-blue-500/50">
           <Info className="h-4 w-4 text-blue-400" />
           <AlertDescription className="text-blue-300 text-sm">
-            <strong>GDPR Compliance:</strong> Customer and order data are NOT stored in our database.
-            They are fetched in real-time from your webshop when accessed via API.
-            Disabling access prevents API calls from retrieving this data.
+            <strong>GDPR Compliance:</strong> Control how data is accessed and stored.
+            "API Only" mode ensures no personal data is cached locally.
+            "Sync & Cache" provides faster access but stores data in our database.
           </AlertDescription>
         </Alert>
 
-        {/* Permission Toggles */}
-        <div className="space-y-4">
+        {/* Policy Settings */}
+        <div className="space-y-6">
           {/* Products Access */}
-          <div className="flex items-start justify-between p-4 bg-slate-700/50 rounded-lg">
-            <div className="flex-1">
-              <div className="flex items-center gap-2 mb-1">
-                <h4 className="text-white font-medium">Product Data Access</h4>
-                <Badge variant="outline" className="text-xs border-blue-500 text-blue-400">
-                  Public Data
-                </Badge>
-              </div>
-              <p className="text-sm text-slate-400">
-                Allow API access to product information (names, prices, descriptions, stock levels)
-              </p>
+          <div className="p-4 bg-slate-700/50 rounded-lg border border-slate-600">
+            <div className="flex items-center gap-2 mb-2">
+              <h4 className="text-white font-medium">Product Data</h4>
+              <Badge variant="outline" className="text-xs border-blue-500 text-blue-400">
+                Public Data
+              </Badge>
             </div>
-            <Switch
-              checked={permissions.allow_product_access}
-              onCheckedChange={() => handleToggle('allow_product_access')}
-              disabled={saving}
-            />
+            <p className="text-sm text-slate-400 mb-3">
+              Product information (names, prices, descriptions, stock levels)
+            </p>
+            {renderPolicyOptions('products', 'products_access_policy', false)}
           </div>
 
           {/* Customers Access */}
-          <div className="flex items-start justify-between p-4 bg-slate-700/50 rounded-lg">
-            <div className="flex-1">
-              <div className="flex items-center gap-2 mb-1">
-                <h4 className="text-white font-medium">Customer Data Access</h4>
-                <Badge variant="outline" className="text-xs border-orange-500 text-orange-400">
-                  Personal Data
-                </Badge>
-              </div>
-              <p className="text-sm text-slate-400">
-                Allow API access to customer information (names, emails, addresses, purchase history)
-              </p>
-              {!permissions.allow_customer_access && (
-                <p className="text-xs text-yellow-400 mt-2">
-                  ⚠️ API requests for customer data will be denied
-                </p>
-              )}
+          <div className="p-4 bg-slate-700/50 rounded-lg border border-slate-600">
+            <div className="flex items-center gap-2 mb-2">
+              <h4 className="text-white font-medium">Customer Data</h4>
+              <Badge variant="outline" className="text-xs border-orange-500 text-orange-400">
+                Personal Data (PII)
+              </Badge>
             </div>
-            <Switch
-              checked={permissions.allow_customer_access}
-              onCheckedChange={() => handleToggle('allow_customer_access')}
-              disabled={saving}
-            />
+            <p className="text-sm text-slate-400 mb-3">
+              Customer information (names, emails, addresses, purchase history)
+            </p>
+            {renderPolicyOptions('customers', 'customers_access_policy', true)}
           </div>
 
           {/* Orders Access */}
-          <div className="flex items-start justify-between p-4 bg-slate-700/50 rounded-lg">
-            <div className="flex-1">
-              <div className="flex items-center gap-2 mb-1">
-                <h4 className="text-white font-medium">Order Data Access</h4>
-                <Badge variant="outline" className="text-xs border-orange-500 text-orange-400">
-                  Personal Data
-                </Badge>
-              </div>
-              <p className="text-sm text-slate-400">
-                Allow API access to order information (order details, amounts, customer info, shipping addresses)
-              </p>
-              {!permissions.allow_order_access && (
-                <p className="text-xs text-yellow-400 mt-2">
-                  ⚠️ API requests for order data will be denied
-                </p>
-              )}
+          <div className="p-4 bg-slate-700/50 rounded-lg border border-slate-600">
+            <div className="flex items-center gap-2 mb-2">
+              <h4 className="text-white font-medium">Order Data</h4>
+              <Badge variant="outline" className="text-xs border-orange-500 text-orange-400">
+                Personal Data (PII)
+              </Badge>
             </div>
-            <Switch
-              checked={permissions.allow_order_access}
-              onCheckedChange={() => handleToggle('allow_order_access')}
-              disabled={saving}
-            />
+            <p className="text-sm text-slate-400 mb-3">
+              Order information (order details, amounts, customer info, shipping addresses)
+            </p>
+            {renderPolicyOptions('orders', 'orders_access_policy', true)}
           </div>
         </div>
 
@@ -232,13 +283,22 @@ export function DataAccessSettings({
           </div>
         )}
 
-        {/* Warning for disabling all access */}
-        {!permissions.allow_customer_access && !permissions.allow_order_access && (
-          <Alert className="bg-yellow-500/10 border-yellow-500/50">
-            <ShieldAlert className="h-4 w-4 text-yellow-400" />
-            <AlertDescription className="text-yellow-300 text-sm">
-              <strong>High Privacy Mode:</strong> Customer and order data access is disabled.
-              Only product data can be accessed via API. This maximizes privacy but limits functionality.
+        {/* Privacy level warnings */}
+        {security.level === 'maximum' && (
+          <Alert className="bg-green-500/10 border-green-500/50">
+            <ShieldCheck className="h-4 w-4 text-green-400" />
+            <AlertDescription className="text-green-300 text-sm">
+              <strong>Maximum Privacy:</strong> All data access is blocked. API tools will not be able to access any store data.
+            </AlertDescription>
+          </Alert>
+        )}
+
+        {(policies.customers_access_policy === 'api_only' || policies.orders_access_policy === 'api_only') && (
+          <Alert className="bg-blue-500/10 border-blue-500/50">
+            <Info className="h-4 w-4 text-blue-400" />
+            <AlertDescription className="text-blue-300 text-sm">
+              <strong>API-Only Mode Active:</strong> Selected data types will be fetched directly from your store on demand.
+              This is slower but ensures no personal data is cached locally, making it GDPR-friendly.
             </AlertDescription>
           </Alert>
         )}

+ 162 - 0
supabase/functions/_shared/access-policy-helpers.ts

@@ -0,0 +1,162 @@
+/**
+ * Access Policy Helpers
+ *
+ * Utilities for checking and enforcing data access policies (GDPR compliance)
+ */
+
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
+import { ToolCallResult } from './mcp-sse.ts';
+
+// Initialize Supabase client
+const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
+const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
+const supabase = createClient(supabaseUrl, supabaseServiceKey);
+
+/**
+ * Data access policy type
+ */
+export type DataAccessPolicy = 'sync' | 'api_only' | 'not_allowed';
+
+/**
+ * Data type for access control
+ */
+export type DataType = 'products' | 'customers' | 'orders';
+
+/**
+ * Access policy configuration
+ */
+export interface AccessPolicyConfig {
+  productsPolicy: DataAccessPolicy;
+  customersPolicy: DataAccessPolicy;
+  ordersPolicy: DataAccessPolicy;
+  platform: string;
+  storeName: string;
+}
+
+/**
+ * Get access policy configuration for a store
+ */
+export async function getAccessPolicyConfig(storeId: string): Promise<AccessPolicyConfig | null> {
+  // Get store info
+  const { data: store, error: storeError } = await supabase
+    .from('stores')
+    .select('platform_name, store_name')
+    .eq('id', storeId)
+    .single();
+
+  if (storeError || !store) {
+    console.error('[Access Policy] Store not found:', storeId);
+    return null;
+  }
+
+  // Get sync config with access policy columns
+  const { data: syncConfig, error: syncError } = await supabase
+    .from('store_sync_config')
+    .select('products_access_policy, customers_access_policy, orders_access_policy')
+    .eq('store_id', storeId)
+    .single();
+
+  // Default to 'sync' if no config exists
+  const config = syncConfig || {
+    products_access_policy: 'sync',
+    customers_access_policy: 'sync',
+    orders_access_policy: 'sync'
+  };
+
+  return {
+    productsPolicy: (config.products_access_policy || 'sync') as DataAccessPolicy,
+    customersPolicy: (config.customers_access_policy || 'sync') as DataAccessPolicy,
+    ordersPolicy: (config.orders_access_policy || 'sync') as DataAccessPolicy,
+    platform: store.platform_name,
+    storeName: store.store_name
+  };
+}
+
+/**
+ * Check if access is allowed for a specific data type
+ */
+export function isAccessAllowed(policy: DataAccessPolicy): boolean {
+  return policy !== 'not_allowed';
+}
+
+/**
+ * Check if sync/cache access is allowed (sync mode)
+ */
+export function isSyncAllowed(policy: DataAccessPolicy): boolean {
+  return policy === 'sync';
+}
+
+/**
+ * Check if only API access is allowed (api_only mode)
+ */
+export function isApiOnlyMode(policy: DataAccessPolicy): boolean {
+  return policy === 'api_only';
+}
+
+/**
+ * Create an access denied error response
+ */
+export function createAccessDeniedError(dataType: DataType): ToolCallResult {
+  return {
+    content: [{
+      type: 'text',
+      text: JSON.stringify({
+        error: `Access to ${dataType} data is not allowed for this store. The store owner has disabled ${dataType} access.`,
+        access_policy: 'not_allowed',
+        data_type: dataType
+      })
+    }],
+    isError: true
+  };
+}
+
+/**
+ * Create an API-only notice response
+ * This is used when we're making a direct API call due to api_only policy
+ */
+export function createApiOnlyNotice(dataType: DataType): string {
+  return `Note: Fetching ${dataType} directly from store API (api_only mode - no caching)`;
+}
+
+/**
+ * Get the appropriate access policy for a data type
+ */
+export function getDataTypePolicy(config: AccessPolicyConfig, dataType: DataType): DataAccessPolicy {
+  switch (dataType) {
+    case 'products':
+      return config.productsPolicy;
+    case 'customers':
+      return config.customersPolicy;
+    case 'orders':
+      return config.ordersPolicy;
+    default:
+      return 'not_allowed';
+  }
+}
+
+/**
+ * Validate access and return appropriate error if not allowed
+ * Returns null if access is allowed
+ */
+export function validateAccess(
+  config: AccessPolicyConfig | null,
+  dataType: DataType
+): ToolCallResult | null {
+  if (!config) {
+    return {
+      content: [{
+        type: 'text',
+        text: JSON.stringify({ error: 'Store configuration not found' })
+      }],
+      isError: true
+    };
+  }
+
+  const policy = getDataTypePolicy(config, dataType);
+
+  if (!isAccessAllowed(policy)) {
+    return createAccessDeniedError(dataType);
+  }
+
+  return null; // Access is allowed
+}

+ 28 - 6
supabase/functions/_shared/mcp-qdrant-helpers.ts

@@ -27,6 +27,11 @@ export interface LlmProduct {
   tags?: string[];
 }
 
+/**
+ * Data access policy type
+ */
+export type DataAccessPolicy = 'sync' | 'api_only' | 'not_allowed';
+
 /**
  * Check if store has Qdrant sync enabled and get sync configuration
  */
@@ -35,6 +40,9 @@ export async function getStoreQdrantConfig(storeId: string): Promise<{
   syncProducts: boolean;
   syncOrders: boolean;
   syncCustomers: boolean;
+  productsAccessPolicy: DataAccessPolicy;
+  customersAccessPolicy: DataAccessPolicy;
+  ordersAccessPolicy: DataAccessPolicy;
   shopname: string;
   platform: string;
 } | null> {
@@ -50,10 +58,10 @@ export async function getStoreQdrantConfig(storeId: string): Promise<{
     return null;
   }
 
-  // Get sync config
+  // Get sync config with new access policy columns
   const { data: syncConfig, error: syncError } = await supabase
     .from('store_sync_config')
-    .select('sync_products, sync_orders, sync_customers')
+    .select('sync_products, sync_orders, sync_customers, products_access_policy, customers_access_policy, orders_access_policy')
     .eq('store_id', storeId)
     .single();
 
@@ -61,14 +69,28 @@ export async function getStoreQdrantConfig(storeId: string): Promise<{
   const config = syncConfig || {
     sync_products: true,
     sync_orders: true,
-    sync_customers: true
+    sync_customers: true,
+    products_access_policy: 'sync' as DataAccessPolicy,
+    customers_access_policy: 'sync' as DataAccessPolicy,
+    orders_access_policy: 'sync' as DataAccessPolicy
   };
 
+  // Use new policy columns, fallback to old boolean columns for backward compatibility
+  const productsPolicy = (config.products_access_policy ||
+    (config.sync_products ? 'sync' : 'not_allowed')) as DataAccessPolicy;
+  const customersPolicy = (config.customers_access_policy ||
+    (config.sync_customers ? 'sync' : 'not_allowed')) as DataAccessPolicy;
+  const ordersPolicy = (config.orders_access_policy ||
+    (config.sync_orders ? 'sync' : 'not_allowed')) as DataAccessPolicy;
+
   return {
     enabled: store.qdrant_sync_enabled !== false, // Default to true
-    syncProducts: config.sync_products !== false, // Products always synced, but check anyway
-    syncOrders: config.sync_orders !== false,
-    syncCustomers: config.sync_customers !== false,
+    syncProducts: productsPolicy === 'sync',
+    syncOrders: ordersPolicy === 'sync',
+    syncCustomers: customersPolicy === 'sync',
+    productsAccessPolicy: productsPolicy,
+    customersAccessPolicy: customersPolicy,
+    ordersAccessPolicy: ordersPolicy,
     shopname: store.store_name,
     platform: store.platform_name
   };

+ 93 - 0
supabase/functions/api/index.ts

@@ -76,6 +76,9 @@ serve(async (req) => {
             sync_products,
             sync_orders,
             sync_customers,
+            products_access_policy,
+            customers_access_policy,
+            orders_access_policy,
             last_sync_at,
             next_sync_at
           )
@@ -566,6 +569,96 @@ serve(async (req) => {
       )
     }
 
+    // PUT /api/stores/:id/access-policies - Update data access policies for a store (GDPR compliance)
+    if (path.match(/^stores\/[^\/]+\/access-policies$/) && req.method === 'PUT') {
+      const storeId = path.split('/')[1]
+      const body = await req.json()
+
+      // Validate the request body
+      const validPolicies = ['products_access_policy', 'customers_access_policy', 'orders_access_policy']
+      const validPolicyValues = ['sync', 'api_only', 'not_allowed']
+
+      for (const key of validPolicies) {
+        if (!(key in body)) {
+          return new Response(
+            JSON.stringify({ error: `Missing required field: ${key}` }),
+            { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+          )
+        }
+
+        if (!validPolicyValues.includes(body[key])) {
+          return new Response(
+            JSON.stringify({ error: `Invalid value for ${key}. Must be one of: ${validPolicyValues.join(', ')}` }),
+            { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+          )
+        }
+      }
+
+      // Verify the store belongs to the user
+      const { data: store, error: storeError } = await supabase
+        .from('stores')
+        .select('id')
+        .eq('id', storeId)
+        .eq('user_id', user.id)
+        .single()
+
+      if (storeError || !store) {
+        return new Response(
+          JSON.stringify({ error: 'Store not found or access denied' }),
+          { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
+      // Use service role client to call the helper function
+      const supabaseAdmin = createClient(
+        Deno.env.get('SUPABASE_URL')!,
+        Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
+      )
+
+      // Update the access policies for each data type
+      const updates = []
+      for (const dataType of ['products', 'customers', 'orders']) {
+        const policyKey = `${dataType}_access_policy`
+        const policy = body[policyKey]
+
+        const { error: updateError } = await supabaseAdmin
+          .rpc('set_data_access_policy', {
+            p_store_id: storeId,
+            p_data_type: dataType,
+            p_policy: policy
+          })
+
+        if (updateError) {
+          console.error(`Error updating ${dataType} access policy:`, updateError)
+          updates.push({ dataType, success: false, error: updateError.message })
+        } else {
+          updates.push({ dataType, success: true })
+        }
+      }
+
+      // Check if all updates succeeded
+      const allSuccess = updates.every(u => u.success)
+
+      if (!allSuccess) {
+        return new Response(
+          JSON.stringify({
+            error: 'Some access policy updates failed',
+            details: updates
+          }),
+          { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+        )
+      }
+
+      return new Response(
+        JSON.stringify({
+          success: true,
+          message: 'Access policies updated successfully',
+          policies: body
+        }),
+        { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      )
+    }
+
     // DELETE /api/stores/{id} - Delete a store
     if (path.startsWith('stores/') && req.method === 'DELETE') {
       const storeId = path.replace('stores/', '')

+ 142 - 71
supabase/functions/mcp-woocommerce/index.ts

@@ -16,6 +16,7 @@ import {
   createInternalApiKeyErrorResponse
 } from '../_shared/internal-api-key-auth.ts';
 import {
+  fetchProducts,
   fetchOrders,
   fetchOrder,
   fetchCustomers,
@@ -42,6 +43,14 @@ import {
   queryQdrantOrders,
   queryQdrantCustomers
 } from '../_shared/mcp-qdrant-helpers.ts';
+import {
+  getAccessPolicyConfig,
+  validateAccess,
+  isSyncAllowed,
+  isApiOnlyMode,
+  getDataTypePolicy,
+  createApiOnlyNotice
+} from '../_shared/access-policy-helpers.ts';
 import {
   JsonRpcRequest,
   createSseResponse,
@@ -236,77 +245,30 @@ async function handleGetProducts(args: Record<string, any>): Promise<ToolCallRes
 
   const actualLimit = Math.min(Math.max(1, limit), 20);
 
-  const { data: store, error: storeError } = await supabase
-    .from('stores')
-    .select('id, platform_name, store_name, data_access_permissions')
-    .eq('id', shop_id)
-    .eq('platform_name', 'woocommerce')
-    .single();
-
-  if (storeError || !store) {
-    return {
-      content: [{ type: 'text', text: JSON.stringify({ error: 'WooCommerce store not found' }) }],
-      isError: true
-    };
+  // Check access policy
+  const accessConfig = await getAccessPolicyConfig(shop_id);
+  const accessError = validateAccess(accessConfig, 'products');
+  if (accessError) {
+    return accessError;
   }
 
-  const permissions = store.data_access_permissions as any;
-  if (permissions && !permissions.allow_product_access) {
-    return {
-      content: [{ type: 'text', text: JSON.stringify({ error: 'Product access not allowed for this store' }) }],
-      isError: true
-    };
-  }
+  const policy = getDataTypePolicy(accessConfig!, 'products');
 
   try {
-    const qdrantConfig = await getStoreQdrantConfig(shop_id);
-
-    if (qdrantConfig && qdrantConfig.enabled && qdrantConfig.syncProducts) {
-      const products = await queryQdrantProducts(
-        shop_id,
-        qdrantConfig.shopname,
-        { sku, name, status, minPrice: min_price, maxPrice: max_price },
-        actualLimit
-      );
-
-      const cleanedProducts = cleanResponseData(products);
-
-      return {
-        content: [{
-          type: 'text',
-          text: JSON.stringify({
-            count: products.length,
-            limit: actualLimit,
-            source: 'qdrant',
-            products: cleanedProducts
-          })
-        }]
-      };
-    } else {
-      let query = supabase
-        .from('woocommerce_products_cache')
-        .select('*')
-        .eq('store_id', shop_id);
-
-      if (sku) query = query.eq('sku', sku);
-      if (status) query = query.eq('stock_status', status);
-
-      query = query.limit(actualLimit);
+    // Handle api_only mode: Direct API call
+    if (isApiOnlyMode(policy)) {
+      console.log('[MCP WooCommerce] Using direct API access (api_only mode)');
 
-      const { data: cachedProducts, error: cacheError } = await query;
+      const filters: any = {};
+      if (sku) filters.sku = sku;
+      if (status) filters.status = status;
 
-      if (cacheError) {
-        return {
-          content: [{
-            type: 'text',
-            text: JSON.stringify({ error: `Failed to fetch products: ${cacheError.message}` })
-          }],
-          isError: true
-        };
-      }
+      // Fetch from WooCommerce API directly
+      const apiProducts = await fetchProducts(shop_id, 1, actualLimit, filters);
 
-      let products = cachedProducts || [];
+      let products = apiProducts;
 
+      // Apply client-side filters
       if (name) {
         const nameLower = name.toLowerCase();
         products = products.filter((p: any) =>
@@ -315,24 +277,26 @@ async function handleGetProducts(args: Record<string, any>): Promise<ToolCallRes
       }
 
       if (min_price !== undefined) {
-        products = products.filter((p: any) => (parseFloat(p.price) || 0) >= min_price);
+        products = products.filter((p: any) => parseFloat(p.price) >= min_price);
       }
 
       if (max_price !== undefined) {
-        products = products.filter((p: any) => (parseFloat(p.price) || 0) <= max_price);
+        products = products.filter((p: any) => parseFloat(p.price) <= max_price);
       }
 
+      // Limit results
       products = products.slice(0, actualLimit);
 
+      // Format for LLM
       const formattedProducts: LlmProduct[] = products.map((p: any) => ({
-        id: p.wc_product_id || p.id,
+        id: p.id.toString(),
         name: p.name,
         sku: p.sku || undefined,
         price: p.price || undefined,
-        currency: p.currency || undefined,
-        status: p.stock_status || p.status || undefined,
+        currency: undefined,
+        status: p.status || undefined,
         description: p.description || undefined,
-        tags: undefined
+        tags: p.tags?.map((t: any) => t.name) || undefined
       }));
 
       const cleanedProducts = cleanResponseData(formattedProducts);
@@ -341,14 +305,121 @@ async function handleGetProducts(args: Record<string, any>): Promise<ToolCallRes
         content: [{
           type: 'text',
           text: JSON.stringify({
-            count: formattedProducts.length,
+            count: products.length,
             limit: actualLimit,
-            source: 'sql_cache',
+            source: 'api_direct',
+            access_mode: 'api_only',
+            notice: createApiOnlyNotice('products'),
             products: cleanedProducts
           })
         }]
       };
     }
+
+    // Handle sync mode: Use Qdrant or SQL cache
+    if (isSyncAllowed(policy)) {
+      const qdrantConfig = await getStoreQdrantConfig(shop_id);
+
+      if (qdrantConfig && qdrantConfig.enabled && qdrantConfig.syncProducts) {
+        const products = await queryQdrantProducts(
+          shop_id,
+          qdrantConfig.shopname,
+          { sku, name, status, minPrice: min_price, maxPrice: max_price },
+          actualLimit
+        );
+
+        const cleanedProducts = cleanResponseData(products);
+
+        return {
+          content: [{
+            type: 'text',
+            text: JSON.stringify({
+              count: products.length,
+              limit: actualLimit,
+              source: 'qdrant',
+              access_mode: 'sync',
+              products: cleanedProducts
+            })
+          }]
+        };
+      } else {
+        let query = supabase
+          .from('woocommerce_products_cache')
+          .select('*')
+          .eq('store_id', shop_id);
+
+        if (sku) query = query.eq('sku', sku);
+        if (status) query = query.eq('stock_status', status);
+
+        query = query.limit(actualLimit);
+
+        const { data: cachedProducts, error: cacheError } = await query;
+
+        if (cacheError) {
+          return {
+            content: [{
+              type: 'text',
+              text: JSON.stringify({ error: `Failed to fetch products: ${cacheError.message}` })
+            }],
+            isError: true
+          };
+        }
+
+        let products = cachedProducts || [];
+
+        if (name) {
+          const nameLower = name.toLowerCase();
+          products = products.filter((p: any) =>
+            (p.name || '').toLowerCase().includes(nameLower)
+          );
+        }
+
+        if (min_price !== undefined) {
+          products = products.filter((p: any) => (parseFloat(p.price) || 0) >= min_price);
+        }
+
+        if (max_price !== undefined) {
+          products = products.filter((p: any) => (parseFloat(p.price) || 0) <= max_price);
+        }
+
+        products = products.slice(0, actualLimit);
+
+        const formattedProducts: LlmProduct[] = products.map((p: any) => ({
+          id: p.wc_product_id || p.id,
+          name: p.name,
+          sku: p.sku || undefined,
+          price: p.price || undefined,
+          currency: p.currency || undefined,
+          status: p.stock_status || p.status || undefined,
+          description: p.description || undefined,
+          tags: undefined
+        }));
+
+        const cleanedProducts = cleanResponseData(formattedProducts);
+
+        return {
+          content: [{
+            type: 'text',
+            text: JSON.stringify({
+              count: formattedProducts.length,
+              limit: actualLimit,
+              source: 'sql_cache',
+              access_mode: 'sync',
+              products: cleanedProducts
+            })
+          }]
+        };
+      }
+    }
+
+    // Fallback (should not reach here)
+    return {
+      content: [{
+        type: 'text',
+        text: JSON.stringify({ error: 'Invalid access policy configuration' })
+      }],
+      isError: true
+    };
   } catch (error) {
     return {
       content: [{

+ 128 - 0
supabase/migrations/20251117_gdpr_access_policy_enums.sql

@@ -0,0 +1,128 @@
+-- Migration: GDPR compliance - 3-state access policy for store data
+-- Date: 2025-11-17
+-- Description: Enhances data access control from 2-state (allowed/not-allowed) to 3-state policy:
+--   - sync: Store data in cache and Qdrant for fast access
+--   - api_only: Direct API access only, no local caching
+--   - not_allowed: No access permitted
+
+-- Create enum type for data access policy
+CREATE TYPE data_access_policy AS ENUM ('sync', 'api_only', 'not_allowed');
+
+-- Add new columns to store_sync_config table
+ALTER TABLE store_sync_config
+  ADD COLUMN products_access_policy data_access_policy DEFAULT 'sync',
+  ADD COLUMN customers_access_policy data_access_policy DEFAULT 'sync',
+  ADD COLUMN orders_access_policy data_access_policy DEFAULT 'sync';
+
+-- Migrate existing boolean values to enum
+-- If sync_products = true, set to 'sync', otherwise 'not_allowed'
+UPDATE store_sync_config
+SET products_access_policy = CASE
+  WHEN sync_products = true THEN 'sync'::data_access_policy
+  ELSE 'not_allowed'::data_access_policy
+END;
+
+UPDATE store_sync_config
+SET customers_access_policy = CASE
+  WHEN sync_customers = true THEN 'sync'::data_access_policy
+  ELSE 'not_allowed'::data_access_policy
+END;
+
+UPDATE store_sync_config
+SET orders_access_policy = CASE
+  WHEN sync_orders = true THEN 'sync'::data_access_policy
+  ELSE 'not_allowed'::data_access_policy
+END;
+
+-- Make new columns NOT NULL after migration
+ALTER TABLE store_sync_config
+  ALTER COLUMN products_access_policy SET NOT NULL,
+  ALTER COLUMN customers_access_policy SET NOT NULL,
+  ALTER COLUMN orders_access_policy SET NOT NULL;
+
+-- Keep old columns for backward compatibility during transition
+-- We'll deprecate them in a future migration
+COMMENT ON COLUMN store_sync_config.sync_products IS 'DEPRECATED: Use products_access_policy instead. Will be removed in future version.';
+COMMENT ON COLUMN store_sync_config.sync_customers IS 'DEPRECATED: Use customers_access_policy instead. Will be removed in future version.';
+COMMENT ON COLUMN store_sync_config.sync_orders IS 'DEPRECATED: Use orders_access_policy instead. Will be removed in future version.';
+
+-- Add comments to new columns
+COMMENT ON COLUMN store_sync_config.products_access_policy IS 'Product data access policy: sync (cache + Qdrant), api_only (direct API only), not_allowed (no access)';
+COMMENT ON COLUMN store_sync_config.customers_access_policy IS 'Customer data access policy: sync (cache + Qdrant), api_only (direct API only), not_allowed (no access)';
+COMMENT ON COLUMN store_sync_config.orders_access_policy IS 'Order data access policy: sync (cache + Qdrant), api_only (direct API only), not_allowed (no access)';
+
+-- Create helper function to get data access policy
+CREATE OR REPLACE FUNCTION get_data_access_policy(
+  p_store_id UUID,
+  p_data_type TEXT
+)
+RETURNS TEXT
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+  v_policy TEXT;
+BEGIN
+  -- Validate data type
+  IF p_data_type NOT IN ('products', 'customers', 'orders') THEN
+    RAISE EXCEPTION 'Invalid data type: %. Must be products, customers, or orders', p_data_type;
+  END IF;
+
+  -- Get the appropriate policy based on data type
+  EXECUTE format(
+    'SELECT %I::TEXT FROM store_sync_config WHERE store_id = $1',
+    p_data_type || '_access_policy'
+  )
+  INTO v_policy
+  USING p_store_id;
+
+  -- Return policy or default to 'sync' if no config exists
+  RETURN COALESCE(v_policy, 'sync');
+END;
+$$;
+
+COMMENT ON FUNCTION get_data_access_policy IS 'Get data access policy for a specific store and data type (products, customers, orders)';
+
+-- Create helper function to update data access policy
+CREATE OR REPLACE FUNCTION set_data_access_policy(
+  p_store_id UUID,
+  p_data_type TEXT,
+  p_policy TEXT
+)
+RETURNS VOID
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+BEGIN
+  -- Validate data type
+  IF p_data_type NOT IN ('products', 'customers', 'orders') THEN
+    RAISE EXCEPTION 'Invalid data type: %. Must be products, customers, or orders', p_data_type;
+  END IF;
+
+  -- Validate policy
+  IF p_policy NOT IN ('sync', 'api_only', 'not_allowed') THEN
+    RAISE EXCEPTION 'Invalid policy: %. Must be sync, api_only, or not_allowed', p_policy;
+  END IF;
+
+  -- Update the appropriate policy based on data type
+  EXECUTE format(
+    'UPDATE store_sync_config SET %I = $1::data_access_policy, updated_at = now() WHERE store_id = $2',
+    p_data_type || '_access_policy'
+  )
+  USING p_policy, p_store_id;
+
+  -- If no rows were updated, insert a new config row
+  IF NOT FOUND THEN
+    -- Insert with the specified policy
+    INSERT INTO store_sync_config (store_id, products_access_policy, customers_access_policy, orders_access_policy)
+    VALUES (
+      p_store_id,
+      CASE WHEN p_data_type = 'products' THEN p_policy::data_access_policy ELSE 'sync'::data_access_policy END,
+      CASE WHEN p_data_type = 'customers' THEN p_policy::data_access_policy ELSE 'sync'::data_access_policy END,
+      CASE WHEN p_data_type = 'orders' THEN p_policy::data_access_policy ELSE 'sync'::data_access_policy END
+    );
+  END IF;
+END;
+$$;
+
+COMMENT ON FUNCTION set_data_access_policy IS 'Set data access policy for a specific store and data type (products, customers, orders)';