Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
b8bcc7b
feat: Add PermissionsController for managing permission configuration…
Cemonix Sep 8, 2025
90c4250
Refactor annotation and asset services to include project ID for scop…
Cemonix Sep 8, 2025
8977f4f
refactor: Update asset service and controller tests to include projec…
Cemonix Sep 8, 2025
5e37479
refactor: Remove permission store and related logic, transitioning to…
Cemonix Sep 8, 2025
e47b4bd
refactor: Introduce AuthorizationService for backend-driven permissio…
Cemonix Sep 8, 2025
6220e4d
refactor: Remove permission store dependency and streamline permissio…
Cemonix Sep 8, 2025
9b2bbc6
refactor: Replace permission store with backend authorization service…
Cemonix Sep 8, 2025
384270e
refactor: Transition to authorization service for permission checks i…
Cemonix Sep 8, 2025
1765204
refactor: Transition to authorization service for permission checks a…
Cemonix Sep 8, 2025
a008547
refactor: Update permission checks and streamline access control acro…
Cemonix Sep 8, 2025
81e3544
refactor: Remove permission store dependency and update interceptor s…
Cemonix Sep 8, 2025
3975378
refactor: Update manager role checks to use backend membership data a…
Cemonix Sep 8, 2025
24dc401
refactor: Update controllers and services to include projectId in tas…
Cemonix Sep 8, 2025
472d853
refactor: Update task service test methods to include projectId in ta…
Cemonix Sep 8, 2025
95e8150
refactor: Update comments in AssetService to clarify asset transfer f…
Cemonix Sep 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontend/src/components/project/labels/LabelSchemeCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,10 @@ import {useToast} from '@/composables/useToast';
import LabelChip from './LabelChip.vue';
import CreateLabelForm from './CreateLabelForm.vue';
import Button from '@/components/common/Button.vue';
import {PERMISSIONS} from '@/services/auth/permissions.types';
import Card from '@/components/common/Card.vue';
import ModalWindow from '@/components/common/modal/ModalWindow.vue';
import {AppLogger} from '@/core/logger/logger';
import {PERMISSIONS} from '@/services/auth/permissions.types';

const logger = AppLogger.createComponentLogger('LabelSchemeCard');

Expand Down Expand Up @@ -216,4 +216,4 @@ onMounted(fetchLabels);
opacity: 0.7;
border-color: var(--color-gray-300);
}
</style>
</style>
5 changes: 4 additions & 1 deletion frontend/src/components/project/workflow/WorkflowCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
v-else
:to="pipelineUrl"
class="action-button secondary"
v-permission="{ permission: PERMISSIONS.WORKFLOW.UPDATE }"
>
<font-awesome-icon :icon="faDiagramProject" />
View Pipeline
Expand All @@ -47,6 +48,7 @@
:to="pipelineUrl"
class="action-button secondary small"
title="View workflow pipeline"
v-permission="{ permission: PERMISSIONS.WORKFLOW.UPDATE }"
>
<font-awesome-icon :icon="faDiagramProject" />
Pipeline
Expand All @@ -63,6 +65,7 @@ import { type ProjectRole } from '@/services/project/project.types';
import { WorkflowNavigationHelper } from '@/core/workflow';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faDiagramProject } from '@fortawesome/free-solid-svg-icons';
import { PERMISSIONS } from '@/services/auth/permissions.types';

interface Props {
workflow: Workflow;
Expand Down Expand Up @@ -233,4 +236,4 @@ const formatDate = (dateString: string): string => {
}
}
}
</style>
</style>
158 changes: 122 additions & 36 deletions frontend/src/composables/usePermissions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { computed, type ComputedRef } from 'vue';
import { computed, reactive, type ComputedRef } from 'vue';
import { useAuthStore } from '@/stores/authStore';
import { usePermissionStore } from '@/stores/permissionStore';
import { authorizationService } from '@/services/auth';
import { useProjectStore } from '@/stores/projectStore';

/**
Expand All @@ -9,7 +9,35 @@ import { useProjectStore } from '@/stores/projectStore';
*/
export function usePermissions() {
const authStore = useAuthStore();
const permissionStore = usePermissionStore();
// Lightweight reactive cache for authorization checks
const resultsCache = reactive<Record<string, boolean>>({});
const pending = new Map<string, Promise<boolean>>();

const makeKey = (perm: string, ctx?: { projectId?: number; global?: boolean }): string => {
const pid = ctx?.projectId ?? projectStore.currentProject?.id ?? 0;
const scope = ctx?.global ? 'g' : 'p';
return `${scope}:${pid}:${perm}`;
};

const ensure = async (perm: string, ctx?: { projectId?: number; global?: boolean }): Promise<boolean> => {
const key = makeKey(perm, ctx);
if (key in resultsCache) return resultsCache[key];
if (pending.has(key)) return pending.get(key)!;
const promise = authorizationService
.authorize({ permission: perm, context: ctx?.projectId ? { projectId: ctx.projectId } : undefined })
.then((allow) => {
resultsCache[key] = allow;
pending.delete(key);
return allow;
})
.catch(() => {
resultsCache[key] = false;
pending.delete(key);
return false;
});
pending.set(key, promise);
return promise;
};
const projectStore = useProjectStore();

// Permission-based checks
Expand All @@ -18,37 +46,81 @@ export function usePermissions() {
* Checks if user has a global permission (available regardless of project)
*/
const hasGlobalPermission = (permission: string): boolean => {
return permissionStore.hasGlobalPermission(permission);
// Trigger async fetch; return cached value if present
void ensure(permission, { global: true });
const key = makeKey(permission, { global: true });
return !!resultsCache[key];
};

/**
* Checks if user has any of the specified global permissions
*/
const hasAnyGlobalPermission = (permissions: string[]): boolean => {
return permissionStore.hasAnyGlobalPermission(permissions);
permissions.forEach(p => void ensure(p, { global: true }));
return permissions.some(p => !!resultsCache[makeKey(p, { global: true })]);
};

/**
* Checks if user has all of the specified global permissions
*/
const hasAllGlobalPermissions = (permissions: string[]): boolean => {
return permissionStore.hasAllGlobalPermissions(permissions);
permissions.forEach(p => void ensure(p, { global: true }));
return permissions.every(p => !!resultsCache[makeKey(p, { global: true })]);
Comment on lines 46 to +68
Copy link

Copilot AI Sep 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The synchronous permission check methods trigger async operations but return immediately with potentially stale cache data. This creates a race condition where the first call always returns false even if the user has permission. Consider returning a default value or implementing a different pattern for initial permission checks.

Copilot uses AI. Check for mistakes.
};

/**
* Checks if user has a permission in the current project
* Checks if user has a permission in the current project (sync; warms cache asynchronously)
*/
const hasProjectPermission = (permission: string): boolean => {
const currentProjectId = projectStore.currentProject?.id;
if (!currentProjectId) return false;
return permissionStore.hasProjectPermission(permission, currentProjectId);
void ensure(permission, { projectId: currentProjectId });
return !!resultsCache[makeKey(permission, { projectId: currentProjectId })];
};

/**
* Asynchronously checks if user has a permission in the current project.
* Resolves after the authoritative backend check.
*/
const hasProjectPermissionAsync = async (permission: string): Promise<boolean> => {
const currentProjectId = projectStore.currentProject?.id;
if (!currentProjectId) return false;
return ensure(permission, { projectId: currentProjectId });
};

/**
* Checks if user has a permission in a specific project
*/
const hasPermissionInProject = (permission: string, projectId: number): boolean => {
return permissionStore.hasProjectPermission(permission, projectId);
if (!projectId) return false;
void ensure(permission, { projectId });
return !!resultsCache[makeKey(permission, { projectId })];
};

/**
* Prefetch a project-scoped permission to warm the cache.
*/
const prefetchProjectPermission = (permission: string, projectId?: number) => {
const pid = projectId ?? projectStore.currentProject?.id;
if (!pid) return Promise.resolve(false);
return ensure(permission, { projectId: pid });
};

/**
* Wraps an action with a project-scoped permission check. If denied, logs and does nothing.
* Usage: const onClick = guardProjectAction(PERMISSIONS.TASK.READ, () => {...})
*/
const guardProjectAction = (permission: string, action: (...args: any[]) => any, projectId?: number) => {
return async (...args: any[]) => {
const pid = projectId ?? projectStore.currentProject?.id;
if (!pid) return;
const allowed = await ensure(permission, { projectId: pid });
if (!allowed) {
// TODO: soft-fail: optional place to integrate a toast/notification
return;
}
return action(...args);
};
};

/**
Expand All @@ -57,7 +129,8 @@ export function usePermissions() {
const hasAnyProjectPermission = (permissions: string[]): boolean => {
const currentProjectId = projectStore.currentProject?.id;
if (!currentProjectId) return false;
return permissionStore.hasAnyProjectPermission(permissions, currentProjectId);
permissions.forEach(p => void ensure(p, { projectId: currentProjectId }));
return permissions.some(p => !!resultsCache[makeKey(p, { projectId: currentProjectId })]);
};

/**
Expand All @@ -66,33 +139,24 @@ export function usePermissions() {
const hasAllProjectPermissions = (permissions: string[]): boolean => {
const currentProjectId = projectStore.currentProject?.id;
if (!currentProjectId) return false;
return permissionStore.hasAllProjectPermissions(permissions, currentProjectId);
permissions.forEach(p => void ensure(p, { projectId: currentProjectId }));
return permissions.every(p => !!resultsCache[makeKey(p, { projectId: currentProjectId })]);
};

/**
* Checks if user has a permission anywhere (global or any project)
*/
const hasAnyPermission = (permission: string): boolean => {
return permissionStore.hasAnyPermission(permission);
};

/**
* Checks if user is a member of the current project
*/
const isCurrentProjectMember = computed(() => {
// Prefer project context when available
const currentProjectId = projectStore.currentProject?.id;
if (!currentProjectId) return false;
return permissionStore.isProjectMember(currentProjectId);
});
if (currentProjectId) {
return hasProjectPermission(permission);
}
return hasGlobalPermission(permission);
};

/**
* Gets all permissions for the current project
*/
const currentProjectPermissions = computed(() => {
const currentProjectId = projectStore.currentProject?.id;
if (!currentProjectId) return new Set<string>();
return permissionStore.getProjectPermissions(currentProjectId);
});
// Project membership proxy: treat project:read as membership capability
const isCurrentProjectMember = computed(() => hasProjectPermission('project:read'));

// Reactive computed permission checkers

Expand Down Expand Up @@ -126,13 +190,33 @@ export function usePermissions() {
const canUpdateProject = computed(() => hasProjectPermission('project:update'));
const canDeleteProject = computed(() => hasProjectPermission('project:delete'));
const canManageProjectMembers = computed(() => hasProjectPermission('projectMember:invite'));
const canManageDataSources = computed(() => hasProjectPermission('dataSource:create'));
const canManageWorkflows = computed(() => hasProjectPermission('workflow:create'));
const canManageLabelSchemes = computed(() => hasProjectPermission('labelScheme:create'));
const canManageDataSources = computed(() => hasProjectPermission('dataSource:update'));
const canManageWorkflows = computed(() => hasProjectPermission('workflow:update'));
const canManageLabelSchemes = computed(() => hasProjectPermission('labelScheme:update'));
const canCreateAnnotations = computed(() => hasProjectPermission('annotation:create'));
const canReviewAnnotations = computed(() => hasProjectPermission('annotation:review'));
const canAssignTasks = computed(() => hasProjectPermission('task:assign'));

// Manager role check: use authoritative role from backend
const isManager = (projectId: number): boolean => {
if (!projectId) return false;
if (projectId === projectStore.currentProject?.id) {
if (!projectStore.currentUserMembership) {
// Load membership in background if missing
void projectStore.loadCurrentUserMembership(projectId);
return false;
}
return projectStore.isCurrentUserManager;
}
// Not the current project: require caller to load membership explicitly via store if needed
return false;
};

const isManagerCurrentProject = computed(() => {
const pid = projectStore.currentProjectId;
return pid ? isManager(pid) : false;
});

return {
// Global permission checks
hasGlobalPermission,
Expand All @@ -141,14 +225,16 @@ export function usePermissions() {

// Project permission checks
hasProjectPermission,
hasProjectPermissionAsync,
prefetchProjectPermission,
guardProjectAction,
hasPermissionInProject,
hasAnyProjectPermission,
hasAllProjectPermissions,

// General permission checks
hasAnyPermission,
isCurrentProjectMember,
currentProjectPermissions,

// Reactive permission checkers
canDoGlobally,
Expand All @@ -168,10 +254,10 @@ export function usePermissions() {
canCreateAnnotations,
canReviewAnnotations,
canAssignTasks,
isManager,
isManagerCurrentProject,

// Store references for advanced usage
permissionStore,
authStore,
projectStore,
};
}
}
Loading
Loading