phase1: RLS simplified to tenant isolation only + canAccess fallback removed + useUserPermissions hook + security kernel docs
This commit is contained in:
@@ -0,0 +1,103 @@
|
|||||||
|
"""Simplify RLS to pure tenant isolation.
|
||||||
|
|
||||||
|
Per architecture review: RLS should be the "safety belt" (tenant isolation only),
|
||||||
|
NOT the "vehicle control" (business authorization). Business authorization
|
||||||
|
(owner_id, sharing, entity_permissions) belongs in the application layer
|
||||||
|
(visibility.py with Defense-in-Depth tenant_id filter).
|
||||||
|
|
||||||
|
Revision ID: 0069
|
||||||
|
Revises: 0068
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision = "0069"
|
||||||
|
down_revision = "0068"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
RLS_TABLES = [
|
||||||
|
"contacts", "addresses", "attachments", "bank_accounts",
|
||||||
|
"contact_folders", "contact_folder_permissions", "entity_permissions",
|
||||||
|
"entity_policies", "event_outbox", "audit_log", "notifications",
|
||||||
|
"saved_filters", "saved_views", "webhooks", "workflow_instances",
|
||||||
|
"workflow_step_history", "sequences", "custom_field_definitions",
|
||||||
|
"custom_field_values", "guest_users", "guest_invitations",
|
||||||
|
"consumer_inbox", "tenant_plugin_activation", "permission_templates",
|
||||||
|
"permission_delegations", "dms_files", "dms_folders",
|
||||||
|
"calendar_events", "calendars", "tasks", "task_lists",
|
||||||
|
"messages", "channels", "entity_links", "tags", "tag_assignments",
|
||||||
|
"mail_accounts", "mail_messages", "mail_folders",
|
||||||
|
"report_templates", "report_generations", "ai_conversations",
|
||||||
|
"ai_messages", "automation_workflows", "automation_runs",
|
||||||
|
"mcp_server_configs", "mcp_client_configs", "system_notifications",
|
||||||
|
]
|
||||||
|
|
||||||
|
CONTACTS_POLICIES_TO_DROP = [
|
||||||
|
"contacts_admin_select", "contacts_owner_select",
|
||||||
|
"contacts_shared_select", "contacts_tenant_owned_select",
|
||||||
|
"contacts_delete_policy", "contacts_insert_policy",
|
||||||
|
"contacts_update_policy",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# 1. Drop all business-logic RLS policies on contacts
|
||||||
|
for policy in CONTACTS_POLICIES_TO_DROP:
|
||||||
|
op.execute(f"DROP POLICY IF EXISTS {policy} ON contacts")
|
||||||
|
|
||||||
|
# 2. Drop old tenant_isolation policy on contacts
|
||||||
|
op.execute("DROP POLICY IF EXISTS contacts_tenant_isolation ON contacts")
|
||||||
|
|
||||||
|
# 3. Create simple tenant isolation for ALL operations on contacts
|
||||||
|
op.execute(
|
||||||
|
"CREATE POLICY contacts_tenant_isolation ON contacts "
|
||||||
|
"FOR ALL "
|
||||||
|
"USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid) "
|
||||||
|
"WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. For all other RLS tables: drop existing policies, create simple tenant isolation
|
||||||
|
for table in RLS_TABLES:
|
||||||
|
if table == "contacts":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if table exists first
|
||||||
|
table_exists = conn.execute(
|
||||||
|
text(f"SELECT 1 FROM information_schema.tables WHERE table_name = '{table}'")
|
||||||
|
).fetchone() is not None
|
||||||
|
|
||||||
|
if not table_exists:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get all existing policies on this table
|
||||||
|
result = conn.execute(
|
||||||
|
text(f"SELECT polname FROM pg_policy WHERE polrelid = '{table}'::regclass")
|
||||||
|
)
|
||||||
|
policies = [row[0] for row in result]
|
||||||
|
|
||||||
|
# Drop each policy
|
||||||
|
for policy in policies:
|
||||||
|
op.execute(f'DROP POLICY IF EXISTS "{policy}" ON {table}')
|
||||||
|
|
||||||
|
# Check if table has tenant_id column
|
||||||
|
col_result = conn.execute(
|
||||||
|
text(f"SELECT 1 FROM information_schema.columns "
|
||||||
|
f"WHERE table_name = '{table}' AND column_name = 'tenant_id'")
|
||||||
|
)
|
||||||
|
has_tenant_id = col_result.fetchone() is not None
|
||||||
|
|
||||||
|
if has_tenant_id:
|
||||||
|
op.execute(
|
||||||
|
f"CREATE POLICY {table}_tenant_isolation ON {table} "
|
||||||
|
"FOR ALL "
|
||||||
|
"USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid) "
|
||||||
|
"WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Security Kernel — Verantwortungstabelle
|
||||||
|
|
||||||
|
## Architektur-Prinzip
|
||||||
|
|
||||||
|
```text
|
||||||
|
Authentifizierung → Tenant Membership → Capability-Prüfung → Objektfilter/ACL → RLS als letzte Barriere
|
||||||
|
```
|
||||||
|
|
||||||
|
## Was was prüft
|
||||||
|
|
||||||
|
| Schicht | Verantwortung | Was geprüft wird | Wo implementiert |
|
||||||
|
|---------|--------------|------------------|------------------|
|
||||||
|
| **Authentifizierung** | User identifizieren | Session-Cookie, CSRF-Token | `deps.py:get_current_user()` |
|
||||||
|
| **Tenant Membership** | User gehört zu Tenant | `UserTenant.status == 'active'` | `deps.py:get_current_user()` |
|
||||||
|
| **Capability (RBAC)** | Darf User grundsätzlich Modul nutzen? | `contacts:read`, `contacts:write`, etc. | `require_permission()` Decorator |
|
||||||
|
| **Objekt-ACL** | Darf User DIESEN Datensatz sehen? | `owner_id == user_id` OR shared via `entity_permissions` | `visibility.py:apply_visibility_filter()` |
|
||||||
|
| **ABAC** | Darf User Datensatz mit bestimmten Attributen sehen? | Policy conditions (status, custom fields) | `policy_service.py:apply_policy_filter()` |
|
||||||
|
| **RLS** | Ist User im richtigen Tenant? | `tenant_id == current_setting('app.current_tenant_id')` | PostgreSQL RLS Policies |
|
||||||
|
|
||||||
|
## Was RLS NICHT mehr prüft (seit Migration 0069)
|
||||||
|
|
||||||
|
- ❌ `owner_id` — das macht `visibility.py`
|
||||||
|
- ❌ `entity_permissions` (sharing) — das macht `visibility.py`
|
||||||
|
- ❌ `is_system_admin` — das macht `visibility.py` (überspringt Filter)
|
||||||
|
- ❌ Business-Autorisierung — das macht die Application Layer
|
||||||
|
|
||||||
|
## Was RLS nur noch prüft
|
||||||
|
|
||||||
|
- ✅ `tenant_id == current_setting('app.current_tenant_id')` — Tenant-Isolation
|
||||||
|
- ✅ `WITH CHECK` für INSERT/UPDATE — verhindert cross-tenant writes
|
||||||
|
|
||||||
|
## Defense-in-Depth
|
||||||
|
|
||||||
|
```text
|
||||||
|
RLS (PostgreSQL) → tenant_id Isolation (Safety Belt)
|
||||||
|
visibility.py (App) → tenant_id + owner_id + sharing (Vehicle Control)
|
||||||
|
```
|
||||||
|
|
||||||
|
Beide Schichten filtern `tenant_id` unabhängig voneinander. Selbst wenn eine Schicht versagt, blockt die andere cross-tenant Zugriff.
|
||||||
|
|
||||||
|
## RLS Policies (nach Migration 0069)
|
||||||
|
|
||||||
|
Alle RLS-enabled Tabellen haben genau eine Policy:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE POLICY {table}_tenant_isolation ON {table}
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid)
|
||||||
|
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid)
|
||||||
|
```
|
||||||
|
|
||||||
|
Keine Business-Logic in RLS. Keine owner_id, keine sharing, keine permissions.
|
||||||
|
|
||||||
|
## Session-Variablen
|
||||||
|
|
||||||
|
| Variable | Wert | Wo gesetzt |
|
||||||
|
|----------|------|------------|
|
||||||
|
| `app.current_tenant_id` | UUID des aktuellen Tenants | `deps.py:set_tenant_context()` |
|
||||||
|
| `app.tenant_id` | UUID des aktuellen Tenants (Alias) | `deps.py:set_tenant_context()` |
|
||||||
|
| `app.current_user_id` | UUID des aktuellen Users | `deps.py:set_user_context()` |
|
||||||
|
| `app.is_system_admin` | `'true'` oder `'false'` | `deps.py:set_user_context()` |
|
||||||
|
| `app.current_user_groups` | Komma-getrennte Group-IDs | `deps.py:set_user_context()` |
|
||||||
|
|
||||||
|
## Test-Verifikation
|
||||||
|
|
||||||
|
8/8 Cross-Tenant Security Tests grün:
|
||||||
|
- ✅ visibility_filter_blocks_cross_tenant
|
||||||
|
- ✅ check_single_entity_access_cross_tenant
|
||||||
|
- ✅ get_visible_ids_tenant_scoped
|
||||||
|
- ✅ entity_permissions_tenant_scoped
|
||||||
|
- ✅ rls_tenant_isolation_policy_exists
|
||||||
|
- ✅ rls_enabled_on_tenant_tables
|
||||||
|
- ✅ rls_disabled_on_system_tables
|
||||||
|
- ✅ tenant_context_variable_consistency
|
||||||
@@ -163,9 +163,6 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
|
|||||||
const { hasPermission, hasFieldAccess } = usePermission();
|
const { hasPermission, hasFieldAccess } = usePermission();
|
||||||
const authUser = useAuthStore((state) => state.user);
|
const authUser = useAuthStore((state) => state.user);
|
||||||
const canAccess = (perm: string): boolean => {
|
const canAccess = (perm: string): boolean => {
|
||||||
if (authUser?.is_system_admin) return true;
|
|
||||||
const perms = authUser?.permissions || [];
|
|
||||||
if (perms.length === 0) return true;
|
|
||||||
return hasPermission(perm);
|
return hasPermission(perm);
|
||||||
};
|
};
|
||||||
const createPersonMutation = useCreateContactPerson();
|
const createPersonMutation = useCreateContactPerson();
|
||||||
|
|||||||
@@ -45,12 +45,9 @@ export function Sidebar() {
|
|||||||
const { hasPermission } = usePermission();
|
const { hasPermission } = usePermission();
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
|
|
||||||
// Fallback: system admin or empty permissions = show everything
|
// Use hasPermission directly — permissions are loaded via useUserPermissions hook
|
||||||
const canAccess = (perm?: string): boolean => {
|
const canAccess = (perm?: string): boolean => {
|
||||||
if (!perm) return true;
|
if (!perm) return true;
|
||||||
if (user?.is_system_admin) return true;
|
|
||||||
const perms = user?.permissions || [];
|
|
||||||
if (perms.length === 0) return true; // No permissions loaded — show all, backend 403 handles it
|
|
||||||
return hasPermission(perm);
|
return hasPermission(perm);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,8 @@ export function TopBar() {
|
|||||||
const logoutMutation = useLogout();
|
const logoutMutation = useLogout();
|
||||||
const minimizedWindows = useWindowStore((s) => s.windows.filter((w) => w.state === 'minimized'));
|
const minimizedWindows = useWindowStore((s) => s.windows.filter((w) => w.state === 'minimized'));
|
||||||
const { hasPermission } = usePermission();
|
const { hasPermission } = usePermission();
|
||||||
|
// Use hasPermission directly — permissions are loaded via useUserPermissions hook
|
||||||
const canAccess = (perm: string): boolean => {
|
const canAccess = (perm: string): boolean => {
|
||||||
if (user?.is_system_admin) return true;
|
|
||||||
const perms = user?.permissions || [];
|
|
||||||
if (perms.length === 0) return true;
|
|
||||||
return hasPermission(perm);
|
return hasPermission(perm);
|
||||||
};
|
};
|
||||||
const restoreWindow = useWindowStore((s) => s.restoreWindow);
|
const restoreWindow = useWindowStore((s) => s.restoreWindow);
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { useCurrentUser } from '@/api/hooks';
|
import { useCurrentUser } from '@/api/hooks';
|
||||||
|
import { useUserPermissions } from '@/hooks/useUserPermissions';
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
const store = useAuthStore();
|
const store = useAuthStore();
|
||||||
const { data, isLoading, isError, error } = useCurrentUser();
|
const { data, isLoading, isError, error } = useCurrentUser();
|
||||||
|
|
||||||
|
// Load permissions after authentication
|
||||||
|
useUserPermissions();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isError) {
|
if (isError) {
|
||||||
const status = (error as any)?.status || 0;
|
const status = (error as any)?.status || 0;
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { apiGet } from '@/api/client';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
interface PermissionsResponse {
|
||||||
|
permissions: string[];
|
||||||
|
denied_permissions: string[];
|
||||||
|
field_permissions: Record<string, any>;
|
||||||
|
is_system_admin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the current user's resolved permissions from /api/v1/auth/me/permissions
|
||||||
|
* and stores them in the authStore.
|
||||||
|
*/
|
||||||
|
export function useUserPermissions() {
|
||||||
|
const { isAuthenticated, setPermissions } = useAuthStore();
|
||||||
|
|
||||||
|
const { data, isSuccess } = useQuery<PermissionsResponse>({
|
||||||
|
queryKey: ['user-permissions'],
|
||||||
|
queryFn: () => apiGet<PermissionsResponse>('/api/v1/auth/me/permissions'),
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSuccess && data) {
|
||||||
|
setPermissions(
|
||||||
|
data.permissions || [],
|
||||||
|
data.is_system_admin || false,
|
||||||
|
data.field_permissions || {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [isSuccess, data, setPermissions]);
|
||||||
|
|
||||||
|
return { data, isSuccess };
|
||||||
|
}
|
||||||
@@ -24,9 +24,6 @@ export function ContactDetailPage() {
|
|||||||
const { hasPermission } = usePermission();
|
const { hasPermission } = usePermission();
|
||||||
const authUser = useAuthStore((state) => state.user);
|
const authUser = useAuthStore((state) => state.user);
|
||||||
const canAccess = (perm: string): boolean => {
|
const canAccess = (perm: string): boolean => {
|
||||||
if (authUser?.is_system_admin) return true;
|
|
||||||
const perms = authUser?.permissions || [];
|
|
||||||
if (perms.length === 0) return true;
|
|
||||||
return hasPermission(perm);
|
return hasPermission(perm);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -45,9 +45,6 @@ export function ContactsListPage() {
|
|||||||
const { hasPermission } = usePermission();
|
const { hasPermission } = usePermission();
|
||||||
const authUser = useAuthStore((state) => state.user);
|
const authUser = useAuthStore((state) => state.user);
|
||||||
const canAccess = (perm: string): boolean => {
|
const canAccess = (perm: string): boolean => {
|
||||||
if (authUser?.is_system_admin) return true;
|
|
||||||
const perms = authUser?.permissions || [];
|
|
||||||
if (perms.length === 0) return true;
|
|
||||||
return hasPermission(perm);
|
return hasPermission(perm);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user