fix: enforce workspace isolation logging

This commit is contained in:
Leon Bösche
2026-01-11 05:01:52 +01:00
parent ac1bd2749c
commit 619b2fe23c
6 changed files with 76 additions and 22 deletions

View File

@@ -191,8 +191,13 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
ResetFileBrowser event, ResetFileBrowser event,
Emitter<FileBrowserState> emit, Emitter<FileBrowserState> emit,
) { ) {
final oldOrgId = _currentOrgId;
final clearedCount = _currentFiles.length;
print(
'[FILE-BROWSER] Switching workspace, oldOrgId=$oldOrgId, newOrgId=${event.nextOrgId}, clearing=$clearedCount files, reloading state',
);
emit(DirectoryInitial()); emit(DirectoryInitial());
_currentOrgId = ''; _currentOrgId = event.nextOrgId;
_currentPath = '/'; _currentPath = '/';
_currentFiles = []; _currentFiles = [];
_filteredFiles = []; _filteredFiles = [];

View File

@@ -62,7 +62,14 @@ class CreateFolder extends FileBrowserEvent {
List<Object> get props => [orgId, parentPath, folderName]; List<Object> get props => [orgId, parentPath, folderName];
} }
class ResetFileBrowser extends FileBrowserEvent {} class ResetFileBrowser extends FileBrowserEvent {
final String nextOrgId;
const ResetFileBrowser(this.nextOrgId);
@override
List<Object> get props => [nextOrgId];
}
class LoadPage extends FileBrowserEvent { class LoadPage extends FileBrowserEvent {
final int page; final int page;

View File

@@ -94,7 +94,7 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
); );
// Reset all dependent blocs // Reset all dependent blocs
permissionBloc.add(PermissionsReset()); permissionBloc.add(PermissionsReset());
fileBrowserBloc.add(ResetFileBrowser()); fileBrowserBloc.add(ResetFileBrowser(event.orgId));
uploadBloc.add(ResetUploads()); uploadBloc.add(ResetUploads());
// Load permissions for the selected org // Load permissions for the selected org
permissionBloc.add(LoadPermissions(event.orgId)); permissionBloc.add(LoadPermissions(event.orgId));
@@ -168,7 +168,7 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
emit(OrganizationLoaded(organizations: updatedOrgs, selectedOrg: newOrg)); emit(OrganizationLoaded(organizations: updatedOrgs, selectedOrg: newOrg));
// Reset blocs and load permissions for new org // Reset blocs and load permissions for new org
permissionBloc.add(PermissionsReset()); permissionBloc.add(PermissionsReset());
fileBrowserBloc.add(ResetFileBrowser()); fileBrowserBloc.add(ResetFileBrowser(newOrg.id));
uploadBloc.add(ResetUploads()); uploadBloc.add(ResetUploads());
permissionBloc.add(LoadPermissions(newOrg.id)); permissionBloc.add(LoadPermissions(newOrg.id));
} catch (e) { } catch (e) {

View File

@@ -67,7 +67,10 @@ class _FileExplorerState extends State<FileExplorer> {
if (oldWidget.orgId != widget.orgId) { if (oldWidget.orgId != widget.orgId) {
// Reset and reload when switching between Personal and Org workspaces // Reset and reload when switching between Personal and Org workspaces
final bloc = context.read<FileBrowserBloc>(); final bloc = context.read<FileBrowserBloc>();
bloc.add(ResetFileBrowser()); print(
'[FILE-BROWSER] UI detected workspace switch, oldOrgId=${oldWidget.orgId}, newOrgId=${widget.orgId}, clearing local view',
);
bloc.add(ResetFileBrowser(widget.orgId));
bloc.add(LoadDirectory(orgId: widget.orgId, path: '/')); bloc.add(LoadDirectory(orgId: widget.orgId, path: '/'));
} }
} }
@@ -824,7 +827,9 @@ class _FileExplorerState extends State<FileExplorer> {
splashColor: Colors.transparent, splashColor: Colors.transparent,
highlightColor: Colors.transparent, highlightColor: Colors.transparent,
onPressed: () { onPressed: () {
final parentPath = _getParentPath(state.currentPath); final parentPath = _getParentPath(
state.currentPath,
);
context.read<FileBrowserBloc>().add( context.read<FileBrowserBloc>().add(
LoadDirectory( LoadDirectory(
orgId: widget.orgId, orgId: widget.orgId,

View File

@@ -3,6 +3,7 @@ package database
import ( import (
"context" "context"
"database/sql" "database/sql"
"log"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -255,7 +256,7 @@ func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership,
} }
// GetOrgFiles returns files for a given organization (top-level folder listing) // GetOrgFiles returns files for a given organization (top-level folder listing)
func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) { func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
if page <= 0 { if page <= 0 {
page = 1 page = 1
} }
@@ -264,22 +265,31 @@ func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, path string, q s
} }
offset := (page - 1) * pageSize offset := (page - 1) * pageSize
orgIDStr := orgID.String()
userIDStr := userID.String()
log.Printf("[DATA-ISOLATION] stage=before, action=list, orgId=%s, userId=%s, fileCount=0, path=%s", orgIDStr, userIDStr, path)
// Basic search and pagination. Returns only direct children of the given path. // Basic search and pagination. Returns only direct children of the given path.
// For root ("/"), we want files where path doesn't contain "/" after the first character. // For root ("/"), we want files where path doesn't contain "/" after the first character.
// For subdirs, we want files where path starts with parent but has no additional "/" after parent. // For subdirs, we want files where path starts with parent but has no additional "/" after parent.
rows, err := db.QueryContext(ctx, ` rows, err := db.QueryContext(ctx, `
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at
FROM files FROM files f
WHERE org_id = $1 WHERE f.org_id = $1
AND path != $2 AND EXISTS (
AND ( SELECT 1
($2 = '/' AND path LIKE '/%' AND path NOT LIKE '/%/%') FROM memberships m
OR ($2 != '/' AND path LIKE $2 || '/%' AND path NOT LIKE $2 || '/%/%') WHERE m.org_id = $1 AND m.user_id = $2
) )
AND ($3 = '' OR name ILIKE '%' || $3 || '%') AND f.path != $3
ORDER BY CASE WHEN type = 'folder' THEN 0 ELSE 1 END, name AND (
LIMIT $4 OFFSET $5 ($3 = '/' AND f.path LIKE '/%' AND f.path NOT LIKE '/%/%')
`, orgID, path, q, pageSize, offset) OR ($3 != '/' AND f.path LIKE $3 || '/%' AND f.path NOT LIKE $3 || '/%/%')
)
AND ($4 = '' OR f.name ILIKE '%' || $4 || '%')
ORDER BY CASE WHEN f.type = 'folder' THEN 0 ELSE 1 END, f.name
LIMIT $5 OFFSET $6
`, orgID, userID, path, q, pageSize, offset)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -303,7 +313,11 @@ func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, path string, q s
} }
files = append(files, f) files = append(files, f)
} }
return files, rows.Err() err = rows.Err()
if err == nil {
log.Printf("[DATA-ISOLATION] stage=after, action=list, orgId=%s, userId=%s, fileCount=%d, path=%s", orgIDStr, userIDStr, len(files), path)
}
return files, err
} }
// GetUserFiles returns files for a user's personal workspace at a given path // GetUserFiles returns files for a user's personal workspace at a given path
@@ -317,10 +331,12 @@ func (db *DB) GetUserFiles(ctx context.Context, userID uuid.UUID, path string, q
offset := (page - 1) * pageSize offset := (page - 1) * pageSize
// Return only direct children of the given path // Return only direct children of the given path
log.Printf("[DATA-ISOLATION] stage=before, action=list, orgId=, userId=%s, fileCount=0, path=%s", userID.String(), path)
rows, err := db.QueryContext(ctx, ` rows, err := db.QueryContext(ctx, `
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
FROM files FROM files
WHERE user_id = $1 WHERE user_id = $1
AND org_id IS NULL
AND path != $2 AND path != $2
AND ( AND (
($2 = '/' AND path LIKE '/%' AND path NOT LIKE '/%/%') ($2 = '/' AND path LIKE '/%' AND path NOT LIKE '/%/%')
@@ -353,7 +369,11 @@ func (db *DB) GetUserFiles(ctx context.Context, userID uuid.UUID, path string, q
} }
files = append(files, f) files = append(files, f)
} }
return files, rows.Err() err = rows.Err()
if err == nil {
log.Printf("[DATA-ISOLATION] stage=after, action=list, orgId=, userId=%s, fileCount=%d, path=%s", userID.String(), len(files), path)
}
return files, err
} }
// CreateFile inserts a file or folder record. orgID or userID may be nil. // CreateFile inserts a file or folder record. orgID or userID may be nil.
@@ -361,16 +381,21 @@ func (db *DB) CreateFile(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUI
var f File var f File
var orgIDVal interface{} var orgIDVal interface{}
var userIDVal interface{} var userIDVal interface{}
orgIDStr := ""
userIDStr := ""
if orgID != nil { if orgID != nil {
orgIDVal = *orgID orgIDVal = *orgID
orgIDStr = orgID.String()
} else { } else {
orgIDVal = nil orgIDVal = nil
} }
if userID != nil { if userID != nil {
userIDVal = *userID userIDVal = *userID
userIDStr = userID.String()
} else { } else {
userIDVal = nil userIDVal = nil
} }
log.Printf("[DATA-ISOLATION] stage=before, action=create, orgId=%s, userId=%s, fileCount=1, path=%s", orgIDStr, userIDStr, path)
err := db.QueryRowContext(ctx, ` err := db.QueryRowContext(ctx, `
INSERT INTO files (org_id, user_id, name, path, type, size) INSERT INTO files (org_id, user_id, name, path, type, size)
@@ -380,6 +405,7 @@ func (db *DB) CreateFile(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUI
if err != nil { if err != nil {
return nil, err return nil, err
} }
log.Printf("[DATA-ISOLATION] stage=after, action=create, orgId=%s, userId=%s, fileCount=1, path=%s", orgIDStr, userIDStr, f.Path)
return &f, nil return &f, nil
} }

View File

@@ -357,6 +357,17 @@ func createOrgHandler(w http.ResponseWriter, r *http.Request, db *database.DB, a
func listFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) { func listFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
// Org ID is provided by middleware.Org // Org ID is provided by middleware.Org
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID) orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok || userIDStr == "" {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
errors.LogError(r, err, "Invalid user id in context")
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
// Query params: path, q (search), page, pageSize // Query params: path, q (search), page, pageSize
path := r.URL.Query().Get("path") path := r.URL.Query().Get("path")
if path == "" { if path == "" {
@@ -372,7 +383,7 @@ func listFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
fmt.Sscanf(ps, "%d", &pageSize) fmt.Sscanf(ps, "%d", &pageSize)
} }
files, err := db.GetOrgFiles(r.Context(), orgID, path, q, page, pageSize) files, err := db.GetOrgFiles(r.Context(), orgID, userID, path, q, page, pageSize)
if err != nil { if err != nil {
errors.LogError(r, err, "Failed to get org files") errors.LogError(r, err, "Failed to get org files")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)