fix(security): Fix critical permission system issues
Problem 1: Remove legacy role bypass - Remove role="admin" string bypass in permissions.py resolve_permissions() - Remove role="admin"/"editor" bypass in auth.py check_permission() - Remove legacy role string fallback in deps.py require_admin/require_write - Add migration 0112: Create Role records for built-in roles and link role_id - KI-Kommentar: Legacy Role Bypass entfernt — alle Admins müssen echte role_id haben Problem 2: Enforce API token scopes - Add _token_scopes check in require_permission() in deps.py - When _token_scopes is set (API token auth), required permission must be in scopes - When _token_scopes not set (session auth), normal permission check applies Problem 3: Migration chain verification - Chain is already linear: 0027→0028_rls_force→0028_user_preferences→0029 - user_preferences table confirmed exists in DB - No duplicate revision IDs found Problem 4: RLS for remaining tenant tables - Add migration 0111: Dynamic RLS activation for any remaining tables with tenant_id - Login tables and global tables explicitly excluded - DB check shows 0 tables currently missing RLS (safety net migration) Problem 5: Permission cache invalidation on tenant switch - Add invalidate_permission_cache() call in switch_tenant() for old tenant - Stale cached permissions from old tenant no longer leak Problem 6+7: Guest system removal - Remove get_current_guest() from deps.py - Remove guest_auth.py router from main.py and routes/__init__.py - Rewrite guests.py to use regular User/UserTenant with role=guest - Remove GuestUser/GuestInvitation from models/__init__.py - Add migration 0113: Migrate guest_users to regular users, drop guest tables - Update frontend GuestLogin/GuestContacts to redirect to normal pages - KI-Kommentar: Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
"""Enable RLS for all remaining tenant tables that still lack RLS after 0108/0109.
|
||||
|
||||
Migration 0108 dynamically discovered tables with tenant_id and enabled RLS.
|
||||
However, new tables may have been added since, or some were missed.
|
||||
|
||||
This migration re-runs the same dynamic discovery to catch any stragglers.
|
||||
|
||||
Login tables (users, user_tenants, tenants, sessions, password_reset_tokens,
|
||||
roles, permissions) are explicitly excluded — they must NOT have RLS.
|
||||
|
||||
Global tables (alembic_version, plugin_migrations, marketplace_listings,
|
||||
sequences, notification_types) are also excluded.
|
||||
|
||||
Revision ID: 0111
|
||||
Revises: 0110
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0111"
|
||||
down_revision = "0110"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# Tables that must never get RLS (global / cross-tenant infrastructure)
|
||||
GLOBAL_TABLES = [
|
||||
"alembic_version",
|
||||
"plugin_migrations",
|
||||
"marketplace_listings",
|
||||
"sequences",
|
||||
"notification_types",
|
||||
]
|
||||
|
||||
# Login-related tables — RLS blocks crm_auth during login flow
|
||||
# See 0108 and 0109 for detailed explanation
|
||||
LOGIN_TABLES = [
|
||||
"users",
|
||||
"user_tenants",
|
||||
"tenants",
|
||||
"sessions",
|
||||
"password_reset_tokens",
|
||||
"roles",
|
||||
"permissions",
|
||||
]
|
||||
|
||||
|
||||
def _exec(sql: str) -> None:
|
||||
op.execute(sql)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Dynamic discovery + RLS activation for any tenant table still missing RLS
|
||||
_exec("""
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
policy_sql TEXT;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT t.table_name
|
||||
FROM information_schema.tables t
|
||||
JOIN information_schema.columns c
|
||||
ON c.table_schema = t.table_schema
|
||||
AND c.table_name = t.table_name
|
||||
AND c.column_name = 'tenant_id'
|
||||
WHERE t.table_schema = 'public'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
AND t.table_name NOT IN (
|
||||
'alembic_version',
|
||||
'plugin_migrations',
|
||||
'marketplace_listings',
|
||||
'sequences',
|
||||
'notification_types',
|
||||
-- Login tables must NOT have RLS
|
||||
'users',
|
||||
'user_tenants',
|
||||
'tenants',
|
||||
'sessions',
|
||||
'password_reset_tokens',
|
||||
'roles',
|
||||
'permissions'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_class pc
|
||||
JOIN pg_namespace pn ON pn.oid = pc.relnamespace
|
||||
WHERE pn.nspname = 'public'
|
||||
AND pc.relname = t.table_name
|
||||
AND pc.relrowsecurity = true
|
||||
)
|
||||
LOOP
|
||||
-- Enable + force RLS
|
||||
EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', r.table_name);
|
||||
EXECUTE format('ALTER TABLE public.%I FORCE ROW LEVEL SECURITY', r.table_name);
|
||||
|
||||
-- Drop stale policies (idempotent)
|
||||
EXECUTE format('DROP POLICY IF EXISTS tenant_isolation ON public.%I', r.table_name);
|
||||
EXECUTE format('DROP POLICY IF EXISTS %s_tenant_isolation ON public.%I', r.table_name, r.table_name);
|
||||
|
||||
-- Create fail-closed policy
|
||||
policy_sql := format(
|
||||
'CREATE POLICY %s_tenant_isolation '
|
||||
'ON public.%I '
|
||||
'FOR ALL '
|
||||
'TO crm_api, crm_worker '
|
||||
'USING (tenant_id = NULLIF(current_setting(''app.current_tenant_id'', true), '''')::uuid) '
|
||||
'WITH CHECK (tenant_id = NULLIF(current_setting(''app.current_tenant_id'', true), '''')::uuid)',
|
||||
r.table_name, r.table_name
|
||||
);
|
||||
EXECUTE policy_sql;
|
||||
|
||||
-- Grant CRUD to crm_api and crm_worker
|
||||
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO crm_api', r.table_name);
|
||||
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO crm_worker', r.table_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Migrate legacy role strings (admin/editor/viewer) to real Role records with role_id.
|
||||
|
||||
⚠️ Legacy Role Bypass entfernt — alle Admins müssen echte role_id haben
|
||||
|
||||
This migration creates Role records for each tenant's built-in roles (admin, editor,
|
||||
viewer) and links UserTenant.role_id to the corresponding Role. After this migration,
|
||||
the legacy role string on UserTenant.role is no longer used for permission checks —
|
||||
all permissions come through the Role-based RBAC system.
|
||||
|
||||
Revision ID: 0112
|
||||
Revises: 0111
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
|
||||
revision = "0112"
|
||||
down_revision = "0111"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# Permission sets for built-in roles
|
||||
ADMIN_PERMISSIONS = ["*:*"]
|
||||
|
||||
EDITOR_PERMISSIONS = [
|
||||
"contacts:read", "contacts:write",
|
||||
"users:read", "roles:read", "audit:read",
|
||||
"attachments:read", "attachments:write",
|
||||
"workflows:read", "workflows:write",
|
||||
"sequences:read", "sequences:write",
|
||||
"addresses:read", "addresses:write",
|
||||
"taxes:read", "taxes:write",
|
||||
"currencies:read", "currencies:write",
|
||||
"notifications:read", "notifications:write",
|
||||
"import_export:read", "import_export:write",
|
||||
"user_preferences:read", "user_preferences:write",
|
||||
]
|
||||
|
||||
VIEWER_PERMISSIONS = [
|
||||
"contacts:read", "users:read", "roles:read",
|
||||
"audit:read", "attachments:read", "workflows:read",
|
||||
"sequences:read", "addresses:read", "taxes:read",
|
||||
"currencies:read", "notifications:read",
|
||||
"import_export:read",
|
||||
"user_preferences:read", "user_preferences:write",
|
||||
]
|
||||
|
||||
GUEST_PERMISSIONS = [
|
||||
"contacts:read",
|
||||
"attachments:read",
|
||||
"user_preferences:read",
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# For each tenant, create Role records for built-in roles and link UserTenant.role_id
|
||||
op.execute("""
|
||||
DO $$
|
||||
DECLARE
|
||||
tenant_rec RECORD;
|
||||
admin_role_id UUID;
|
||||
editor_role_id UUID;
|
||||
viewer_role_id UUID;
|
||||
guest_role_id UUID;
|
||||
BEGIN
|
||||
FOR tenant_rec IN SELECT id FROM tenants WHERE deleted_at IS NULL
|
||||
LOOP
|
||||
-- Create or find admin role for this tenant
|
||||
SELECT id INTO admin_role_id
|
||||
FROM roles
|
||||
WHERE tenant_id = tenant_rec.id
|
||||
AND name = 'admin'
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1;
|
||||
|
||||
IF admin_role_id IS NULL THEN
|
||||
INSERT INTO roles (id, tenant_id, name, permissions, denied_permissions, field_permissions, permission_version, created_at)
|
||||
VALUES (
|
||||
gen_random_uuid(),
|
||||
tenant_rec.id,
|
||||
'admin',
|
||||
'["*:*"]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'{}'::jsonb,
|
||||
1,
|
||||
now()
|
||||
)
|
||||
RETURNING id INTO admin_role_id;
|
||||
END IF;
|
||||
|
||||
-- Create or find editor role for this tenant
|
||||
SELECT id INTO editor_role_id
|
||||
FROM roles
|
||||
WHERE tenant_id = tenant_rec.id
|
||||
AND name = 'editor'
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1;
|
||||
|
||||
IF editor_role_id IS NULL THEN
|
||||
INSERT INTO roles (id, tenant_id, name, permissions, denied_permissions, field_permissions, permission_version, created_at)
|
||||
VALUES (
|
||||
gen_random_uuid(),
|
||||
tenant_rec.id,
|
||||
'editor',
|
||||
'["contacts:read","contacts:write","users:read","roles:read","audit:read","attachments:read","attachments:write","workflows:read","workflows:write","sequences:read","sequences:write","addresses:read","addresses:write","taxes:read","taxes:write","currencies:read","currencies:write","notifications:read","notifications:write","import_export:read","import_export:write","user_preferences:read","user_preferences:write"]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'{}'::jsonb,
|
||||
1,
|
||||
now()
|
||||
)
|
||||
RETURNING id INTO editor_role_id;
|
||||
END IF;
|
||||
|
||||
-- Create or find viewer role for this tenant
|
||||
SELECT id INTO viewer_role_id
|
||||
FROM roles
|
||||
WHERE tenant_id = tenant_rec.id
|
||||
AND name = 'viewer'
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1;
|
||||
|
||||
IF viewer_role_id IS NULL THEN
|
||||
INSERT INTO roles (id, tenant_id, name, permissions, denied_permissions, field_permissions, permission_version, created_at)
|
||||
VALUES (
|
||||
gen_random_uuid(),
|
||||
tenant_rec.id,
|
||||
'viewer',
|
||||
'["contacts:read","users:read","roles:read","audit:read","attachments:read","workflows:read","sequences:read","addresses:read","taxes:read","currencies:read","notifications:read","import_export:read","user_preferences:read","user_preferences:write"]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'{}'::jsonb,
|
||||
1,
|
||||
now()
|
||||
)
|
||||
RETURNING id INTO viewer_role_id;
|
||||
END IF;
|
||||
|
||||
-- Create or find guest role for this tenant
|
||||
SELECT id INTO guest_role_id
|
||||
FROM roles
|
||||
WHERE tenant_id = tenant_rec.id
|
||||
AND name = 'guest'
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1;
|
||||
|
||||
IF guest_role_id IS NULL THEN
|
||||
INSERT INTO roles (id, tenant_id, name, permissions, denied_permissions, field_permissions, permission_version, created_at)
|
||||
VALUES (
|
||||
gen_random_uuid(),
|
||||
tenant_rec.id,
|
||||
'guest',
|
||||
'["contacts:read","attachments:read","user_preferences:read"]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'{}'::jsonb,
|
||||
1,
|
||||
now()
|
||||
)
|
||||
RETURNING id INTO guest_role_id;
|
||||
END IF;
|
||||
|
||||
-- Link UserTenant records to the appropriate Role based on legacy role string
|
||||
UPDATE user_tenants SET role_id = admin_role_id
|
||||
WHERE tenant_id = tenant_rec.id
|
||||
AND role = 'admin'
|
||||
AND role_id IS NULL;
|
||||
|
||||
UPDATE user_tenants SET role_id = editor_role_id
|
||||
WHERE tenant_id = tenant_rec.id
|
||||
AND role = 'editor'
|
||||
AND role_id IS NULL;
|
||||
|
||||
UPDATE user_tenants SET role_id = viewer_role_id
|
||||
WHERE tenant_id = tenant_rec.id
|
||||
AND role = 'viewer'
|
||||
AND role_id IS NULL;
|
||||
|
||||
UPDATE user_tenants SET role_id = guest_role_id
|
||||
WHERE tenant_id = tenant_rec.id
|
||||
AND role = 'guest'
|
||||
AND role_id IS NULL;
|
||||
END LOOP;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Unlink role_id for built-in role mappings (keep the Role records)
|
||||
op.execute("""
|
||||
UPDATE user_tenants SET role_id = NULL
|
||||
WHERE role IN ('admin', 'editor', 'viewer', 'guest')
|
||||
AND role_id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM roles r
|
||||
WHERE r.id = user_tenants.role_id
|
||||
AND r.name IN ('admin', 'editor', 'viewer', 'guest')
|
||||
);
|
||||
""")
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Migrate guest_users to regular users with role='guest' and drop guest tables.
|
||||
|
||||
⚠️ Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest
|
||||
|
||||
This migration:
|
||||
1. Creates User records for each guest (or links to existing users by email)
|
||||
2. Creates UserTenant records with role='guest' and appropriate status
|
||||
3. Drops guest_invitations and guest_users tables
|
||||
|
||||
After this migration, guests authenticate via the normal login flow and are
|
||||
managed through the regular user system with role='guest' in user_tenants.
|
||||
|
||||
Revision ID: 0113
|
||||
Revises: 0112
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0113"
|
||||
down_revision = "0112"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Migrate guest_users into users + user_tenants with role='guest'
|
||||
op.execute("""
|
||||
DO $$
|
||||
DECLARE
|
||||
guest_rec RECORD;
|
||||
existing_user_id UUID;
|
||||
new_user_id UUID;
|
||||
mapped_status TEXT;
|
||||
BEGIN
|
||||
FOR guest_rec IN SELECT * FROM guest_users WHERE deleted_at IS NULL OR deleted_at IS NULL
|
||||
LOOP
|
||||
-- Map guest status to user_tenants status
|
||||
mapped_status := CASE
|
||||
WHEN guest_rec.status = 'active' THEN 'active'
|
||||
WHEN guest_rec.status = 'invited' THEN 'invited'
|
||||
WHEN guest_rec.status = 'expired' THEN 'disabled'
|
||||
WHEN guest_rec.status = 'revoked' THEN 'disabled'
|
||||
ELSE 'disabled'
|
||||
END;
|
||||
|
||||
-- Check if a user with this email already exists
|
||||
SELECT id INTO existing_user_id
|
||||
FROM users
|
||||
WHERE email = guest_rec.email
|
||||
LIMIT 1;
|
||||
|
||||
IF existing_user_id IS NOT NULL THEN
|
||||
-- User already exists — just create the tenant membership if missing
|
||||
new_user_id := existing_user_id;
|
||||
|
||||
-- Check if user_tenants entry already exists for this user+tenant
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM user_tenants
|
||||
WHERE user_id = new_user_id
|
||||
AND tenant_id = guest_rec.tenant_id
|
||||
) THEN
|
||||
INSERT INTO user_tenants (user_id, tenant_id, is_default, role, status, created_at, updated_at)
|
||||
VALUES (
|
||||
new_user_id,
|
||||
guest_rec.tenant_id,
|
||||
false,
|
||||
'guest',
|
||||
mapped_status,
|
||||
guest_rec.created_at,
|
||||
guest_rec.updated_at
|
||||
);
|
||||
END IF;
|
||||
ELSE
|
||||
-- Create new user from guest record
|
||||
INSERT INTO users (id, email, name, password_hash, is_active, preferences, is_system_admin, created_at, updated_at)
|
||||
VALUES (
|
||||
gen_random_uuid(),
|
||||
guest_rec.email,
|
||||
guest_rec.name,
|
||||
COALESCE(guest_rec.password_hash, ''),
|
||||
true,
|
||||
'{}'::jsonb,
|
||||
false,
|
||||
guest_rec.created_at,
|
||||
guest_rec.updated_at
|
||||
)
|
||||
RETURNING id INTO new_user_id;
|
||||
|
||||
-- Create user_tenants membership with guest role
|
||||
INSERT INTO user_tenants (user_id, tenant_id, is_default, role, status, created_at, updated_at)
|
||||
VALUES (
|
||||
new_user_id,
|
||||
guest_rec.tenant_id,
|
||||
false,
|
||||
'guest',
|
||||
mapped_status,
|
||||
guest_rec.created_at,
|
||||
guest_rec.updated_at
|
||||
);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
# Drop guest tables
|
||||
op.execute("DROP TABLE IF EXISTS guest_invitations CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS guest_users CASCADE")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreate guest_users table (data is lost — this is a one-way migration)
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS guest_users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
invited_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'invited',
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS guest_invitations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
guest_user_id UUID NOT NULL REFERENCES guest_users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
Reference in New Issue
Block a user