diff --git a/.claude/ARCHITECTURE.md b/.claude/ARCHITECTURE.md new file mode 100644 index 0000000..abbd0a0 --- /dev/null +++ b/.claude/ARCHITECTURE.md @@ -0,0 +1,268 @@ +# myCRM Architecture Overview + +## Quick Reference for Claude + +### Security Architecture +This codebase uses a **multi-layered security system** combining: +1. **Symfony Authentication** - User login, password hashing via bcrypt +2. **Role-Based Access Control (RBAC)** - Users → Roles → Modules → Permissions +3. **Custom Voters** - Fine-grained entity-level authorization +4. **API Platform** - Per-operation security expressions +5. **Query Extensions** - Automatic filtering of accessible data + +See `/SECURITY_ARCHITECTURE.md` for comprehensive details. + +### Entity Relationships - Quick Diagram + +``` +User +├── Standard Roles: ROLE_ADMIN, ROLE_USER (in 'roles' JSON column) +├── Application Roles: Collection (ManyToMany) +│ └── Role +│ └── Permissions: Collection (OneToMany) +│ └── RolePermission +│ └── Module (the module being granted permission for) +└── Can belong to Project teams: Collection (ManyToMany) + +Project +├── owner: User (REQUIRED) +├── teamMembers: Collection (ManyToMany) +├── customer: Contact +├── status: ProjectStatus +└── gitRepositories: Collection (OneToMany) + +ProjectTask +├── project: Project (nullable - can be project-independent) +└── Budget & hourly tracking + +GitRepository +├── project: Project (REQUIRED) +└── URL, branch, provider info + +Contact +├── contactPersons: Collection (OneToMany) +└── Company info: name, address, tax numbers, etc. + +ContactPerson +└── contact: Contact (ManyToOne - no separate permissions) +``` + +### Permission Hierarchy + +**Level 1: Symfony Standard Roles** +- Checked in `config/packages/security.yaml` access_control +- Used for basic authentication gates (API requires `IS_AUTHENTICATED_FULLY`) + +**Level 2: Module-Based Permissions** +- `User.hasModulePermission(moduleCode, action)` checks: + - User's application roles + - Each role's RolePermission for the module + - The specific action flag (canView, canCreate, canEdit, etc.) + +**Level 3: Object-Level Voters** +- `ModuleVoter` - Module-level permissions (string subject or ModuleAwareInterface) +- `ProjectVoter` - Ownership/team access to projects +- `ProjectTaskVoter` - Project-based + admin-only for standalone tasks +- `GitRepositoryVoter` - Project-based access + +**Level 4: Event Listeners** +- `ProjectTaskSecurityListener` - Additional prePersist check + +### Core Files to Know + +**Security Components:** +- `src/Security/Voter/ModuleVoter.php` - Routes to `user.hasModulePermission()` +- `src/Security/Voter/ProjectVoter.php` - Checks `project.owner === user` or team +- `src/Security/Voter/ProjectTaskVoter.php` - Combines project + admin checks +- `src/Security/Voter/GitRepositoryVoter.php` - Tied to project access + +**Entities:** +- `src/Entity/User.php` - hasModulePermission() method +- `src/Entity/Role.php` - permissions collection +- `src/Entity/RolePermission.php` - defines per-module flags +- `src/Entity/Module.php` - registry of modules +- `src/Entity/Project.php` - hasAccess() checks owner/team +- `src/Entity/Interface/ModuleAwareInterface.php` - getModuleName() + +**API Configuration:** +- `config/packages/api_platform.yaml` - Global API defaults +- `config/packages/security.yaml` - Firewall, access_control +- Entity `#[ApiResource]` attributes - Per-operation security expressions + +### API Filters + +Most entities have API filters: +- `SearchFilter` - Partial text search +- `BooleanFilter` - Filter by boolean fields +- `DateFilter` - Filter by date ranges +- Custom `ProjectTaskProjectFilter` - hasProject filter for tasks + +### Authentication Methods + +1. **Form Login** - `LoginFormAuthenticator` - Email + password form +2. **OAuth** - `PocketIdAuthenticator` - OIDC provider integration + +Both update `lastLoginAt` in LoginSuccessListener. + +### Data Serialization + +All APIs use serializer groups to control what's returned: +- `{entity}:read` - Fields returned in GET +- `{entity}:write` - Fields accepted in POST/PUT + +Example: User with `#[Groups(['user:read', 'project:read'])]` appears in both user and project responses. + +### Key Patterns + +**Module-Based Permission Check:** +```php +is_granted('VIEW', 'contacts') // Collection operation +↓ +ModuleVoter.supports() → true +↓ +ModuleVoter.voteOnAttribute('VIEW', 'contacts', $token) +↓ +user.hasModulePermission('contacts', 'view') +``` + +**Object-Level Access Check:** +```php +is_granted('DELETE', $project) // Delete operation +↓ +ProjectVoter.supports() → true +↓ +ProjectVoter.voteOnAttribute('DELETE', $project, $token) +↓ +project.getOwner() === $user +``` + +**Project-Related Filtering:** +``` +ProjectAccessExtension applies to Project GetCollection +↓ +Adds: WHERE owner = :user OR teamMembers = :user +↓ +User sees only their projects +``` + +### Adding New Features + +**New Module-Based Entity:** +1. Implement `ModuleAwareInterface::getModuleName()` +2. Create `Module` record in database +3. Add API security: `new GetCollection(security: "is_granted('VIEW', 'module_code')")` +4. Create RolePermissions for existing roles + +**New Project-Related Entity:** +1. Add `project: Project` relationship +2. Create custom Voter like `ProjectTaskVoter` +3. Use in API: `new Get(security: "is_granted('VIEW', object)")` + +**New User Role:** +1. Create `Role` entity +2. Add `RolePermission` records for each module +3. Assign to users via `user.addUserRole(role)` + +### Common Security Attributes + +Used in voters and API operations: +- `VIEW` - Can read/list +- `CREATE` - Can create new +- `EDIT` - Can update +- `DELETE` - Can remove +- `EXPORT` - Can export data +- `MANAGE` - Admin functions + +### Important Constants + +**Voter Attributes:** +```php +ProjectVoter::VIEW +ProjectVoter::EDIT +ProjectVoter::DELETE +ProjectVoter::CREATE +// Similar for other voters +``` + +**API Platform Groups:** +``` +{entity}:read → Normalization (GET) +{entity}:write → Denormalization (POST/PUT) +``` + +**Symfony Standard Roles:** +``` +ROLE_USER → Authenticated user +ROLE_ADMIN → All permissions (bypasses voters) +``` + +--- + +## Quick Debugging Checklist + +1. **User can't access API endpoint?** + - Check `access_control` in security.yaml + - Check API operation security expression + - Check voter implementation + +2. **User has role but can't perform action?** + - Verify RolePermission exists for that role+module + - Check the specific permission flag (canView, etc.) + - Verify module code matches (case-sensitive!) + +3. **Non-admin accessing admin resource?** + - Check ROLE_ADMIN bypass in voter + - Verify voter supports() returns true + - Look at voteOnAttribute() logic + +4. **Permission check in code?** + ```php + // Get current user + $user = $this->getUser(); // null if not authenticated + + // Check module permission + if ($user->hasModulePermission('contacts', 'create')) { + // Allow + } + + // In Twig + {% if app.user.hasModulePermission('contacts', 'view') %}...{% endif %} + + // In security expressions + is_granted('CREATE', 'contacts') + is_granted('EDIT', $contact) + ``` + +--- + +## Files Changed Recently + +Check git status to see security-related changes: +- `src/Security/Voter/*.php` - Voter implementations +- `src/Entity/*.php` - Entity definitions +- `src/EventListener/*.php` - Security listeners +- `config/packages/security.yaml` - Auth configuration + +--- + +## Testing Security + +**Unit Testing:** +- Create user and project fixtures +- Test voter returns ALLOW/DENY/ABSTAIN correctly +- Test edge cases (null owner, empty team, etc.) + +**Integration Testing:** +- Make API calls as different users +- Verify 403 Forbidden for unauthorized access +- Verify 200 OK for authorized access +- Check returned data respects serializer groups + +--- + +## See Also + +- `/SECURITY_ARCHITECTURE.md` - Complete security documentation +- `/docs/PERMISSIONS.md` - User management and permissions guide +- `config/packages/security.yaml` - Security configuration +- API Platform docs: https://api-platform.com/docs/core/security/ diff --git a/.env b/.env index 0c07879..90504b3 100644 --- a/.env +++ b/.env @@ -63,3 +63,12 @@ OAUTH_POCKET_ID_CLIENT_ID=2e698201-8a79-4598-9b7d-81b57289c340 OAUTH_POCKET_ID_CLIENT_SECRET= OAUTH_POCKET_ID_REDIRECT_URI=https://mycrm.test/dashboard ###< knpuniversity/oauth2-client-bundle ### + +###> app/git-services ### +# Optional: GitHub Personal Access Token for higher rate limits (5000 req/hour instead of 60) +# Create at: https://github.com/settings/tokens (needs 'public_repo' scope for public repos) +GITHUB_TOKEN= + +# Optional: Gitea Access Token for private instances +GITEA_TOKEN= +###< app/git-services ### diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..95d63ca --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,397 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +myCRM is a modern, modular CRM application built with Symfony 7.1 LTS, Vue.js 3, and PrimeVue. It features a sophisticated permission system, API Platform for REST APIs, and a single-page application architecture. + +## Common Development Commands + +### Initial Setup +```bash +composer install +php bin/console doctrine:database:create +php bin/console doctrine:migrations:migrate +php bin/console doctrine:fixtures:load # Load test data (admin@mycrm.local/admin123) +npm install +npm run dev +``` + +### Development Workflow +```bash +# Terminal 1: Backend server +symfony serve -d # OR: php -S localhost:8000 -t public/ + +# Terminal 2: Frontend with hot reload +npm run watch + +# Production build +npm run build +``` + +### Database Operations +```bash +# Create new migration after entity changes +php bin/console make:migration +php bin/console doctrine:migrations:migrate + +# Validate schema matches entities +php bin/console doctrine:schema:validate + +# Reset database (dev only) +php bin/console doctrine:database:drop --force +php bin/console doctrine:database:create +php bin/console doctrine:migrations:migrate +php bin/console doctrine:fixtures:load +``` + +### Testing +```bash +# Backend tests +php bin/phpunit + +# Validate Doctrine schema +php bin/console doctrine:schema:validate + +# Check user permissions (custom command) +php bin/console app:user:permissions +``` + +### Code Generation +```bash +# Entity with API Platform support +php bin/console make:entity + +# Migration after entity changes +php bin/console make:migration + +# Security voter +php bin/console make:voter + +# API Platform state processor/provider +php bin/console make:state-processor +php bin/console make:state-provider +``` + +### Cache Management +```bash +php bin/console cache:clear +APP_ENV=prod php bin/console cache:warmup +``` + +## Architecture Overview + +### Security System (6 Layers) + +The application uses a sophisticated multi-layer security architecture: + +1. **Authentication Layer**: Symfony form login + OAuth (PocketId integration) +2. **Standard Roles**: `ROLE_ADMIN`, `ROLE_USER` (stored in User.roles as JSON array) +3. **Module Permission System**: Custom fine-grained permissions + - User → UserRoles (many-to-many) → Role → RolePermissions → Module + - 6 permission types: view, create, edit, delete, export, manage + - Checked via: `$user->hasModulePermission('contacts', 'view')` +4. **Custom Voters**: Entity-level access control (4 voters) + - `ModuleVoter`: Routes permission checks to module system + - `ProjectVoter`: Project ownership + team member checks + - `ProjectTaskVoter`: Inherits project access + admin bypass + - `GitRepositoryVoter`: Requires associated project access +5. **Event Listeners**: Pre-persist security validation + - `ProjectTaskSecurityListener`: Validates project tasks can only be created if user has project access +6. **Query Extensions**: Auto-filter collections + - `ProjectAccessExtension`: Automatically filters project collections based on user access + +### Permission Check Flow + +```php +// 1. Check via Security component +$this->denyAccessUnlessGranted('view', $contact); // Uses ContactVoter → ModuleVoter + +// 2. Direct module check +if ($user->hasModulePermission('contacts', 'edit')) { } + +// 3. API Platform security +#[ApiResource( + operations: [ + new Get(security: "is_granted('VIEW', object)") + ] +)] +``` + +### Entity Relationships + +**Core Entities:** +- **User**: Authentication + role management (dual system: Symfony roles + custom UserRoles) +- **Role**: Groups of module permissions +- **Module**: CRM modules (contacts, projects, tasks, etc.) +- **RolePermission**: Joins Role ↔ Module with 6 boolean flags +- **Contact**: Business contacts (implements ModuleAwareInterface, module='contacts') +- **ContactPerson**: Linked to Contact, inherits permissions +- **Project**: Project management (owner + team members, module='projects') +- **ProjectTask**: Subtasks under projects (standalone tasks admin-only, module='tasks') +- **GitRepository**: Linked to projects, inherits project access + +**Key Patterns:** +- **ModuleAwareInterface**: Entities that tie to permission modules +- **Project Access Model**: `$project->hasAccess($user)` returns true if owner OR team member +- **Permission Inheritance**: ContactPerson inherits Contact permissions, GitRepository inherits Project permissions +- **Dual Role System**: User.roles (Symfony standard array) + User.userRoles (ManyToMany with Role entity) + +### API Platform Configuration + +**Per-Entity Security:** +```php +#[ApiResource( + operations: [ + new GetCollection(security: "is_granted('VIEW', 'contacts')"), + new Get(security: "is_granted('VIEW', object)"), + new Post(security: "is_granted('CREATE', 'contacts')"), + new Put(security: "is_granted('EDIT', object)"), + new Delete(security: "is_granted('DELETE', object)") + ] +)] +``` + +**Serialization Groups:** +- `{entity}:read`: Fields exposed in GET responses +- `{entity}:write`: Fields accepted in POST/PUT +- Used to control field visibility based on context + +**Common Filters:** +- `SearchFilter`: Text search on specific fields +- `BooleanFilter`: Filter by boolean fields (active, isDebtor, etc.) +- `DateFilter`: Filter by date ranges +- Custom filters for complex queries (e.g., ProjectTaskProjectFilter) + +### Frontend Architecture (Vue.js 3) + +**Directory Structure:** +``` +/assets/js + /components - Reusable components (CrudDataTable, AppMenu, AppTopbar) + /views - Page-level components (ContactManagement, ProjectManagement, Dashboard) + /layout - Sakai layout components (AppLayout, AppSidebar) + /composables - Composition API functions (useAuth, usePermissions) + /stores - Pinia state management + /api - API client wrappers + /router.js - Vue Router SPA navigation + +/assets/styles + /layout - Sakai SCSS (topbar, sidebar, menu, responsive) + tailwind.css - Tailwind CSS v4 with PrimeUI plugin + sakai.scss - Sakai layout imports +``` + +**Component Pattern:** +```vue + +``` + +**PrimeVue Usage:** +- `DataTable`: Main component for entity lists (server-side pagination, filtering, sorting) +- `Dialog`: Modal forms for create/edit +- `Button`, `InputText`, `Dropdown`: Form components +- `Chart`: Dashboard visualizations (Chart.js integration) +- Theme: Aura theme with dark mode support + +**Webpack Encore:** +- Entry point: `assets/app.js` +- Aliases: `@` → `assets/js`, `@images` → `assets/images` +- Hot reload: `npm run watch` +- Production build: `npm run build` + +## Development Conventions + +### Backend (Symfony) + +**Controllers**: Keep thin, delegate to services +```php +// Good +public function create(Request $request, ContactService $service): JsonResponse +{ + $this->denyAccessUnlessGranted('CREATE', 'contacts'); + return $this->json($service->createContact($request->toArray())); +} +``` + +**Services**: Constructor injection, use interfaces +```php +class ContactService +{ + public function __construct( + private EntityManagerInterface $em, + private EventDispatcherInterface $dispatcher + ) {} +} +``` + +**Entities**: Doctrine attributes, define relationships +```php +#[ORM\Entity] +#[ORM\Table(name: 'contacts')] +#[ApiResource(/* ... */)] +class Contact implements ModuleAwareInterface +{ + public function getModuleName(): string { return 'contacts'; } +} +``` + +**Voters**: Implement granular permissions +```php +class ContactVoter extends Voter +{ + protected function supports(string $attribute, mixed $subject): bool + { + return in_array($attribute, ['VIEW', 'EDIT', 'DELETE']) + && ($subject instanceof Contact || $subject === 'contacts'); + } + + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool + { + // Delegate to ModuleVoter via hasModulePermission() + } +} +``` + +### Frontend (Vue.js) + +**Composables**: Extract reusable logic +```javascript +// composables/usePermissions.js +export function usePermissions() { + const hasPermission = (module, action) => { + // Check user permissions + } + return { hasPermission } +} +``` + +**Components**: Single File Components with Composition API +```vue + +``` + +## Testing Strategy + +- **PHPUnit**: Backend unit and functional tests +- **Doctrine Schema Validation**: Run in CI to catch schema drift +- **Voter Tests**: Explicitly test permission logic +- **Frontend**: Vitest/Jest for Vue components (when configured) + +## Important Files + +**Security:** +- `src/Security/Voter/` - All voter implementations +- `src/EventListener/ProjectTaskSecurityListener.php` - Pre-persist validation +- `src/Filter/ProjectAccessExtension.php` - Query auto-filtering +- `src/Entity/User.php` - hasModulePermission() method (lines ~200-220) + +**API Configuration:** +- Entity `#[ApiResource]` attributes - Security, operations, filters +- `config/packages/api_platform.yaml` - Global API settings + +**Frontend Entry:** +- `assets/app.js` - Vue app initialization +- `assets/js/router.js` - Route definitions and navigation guards +- `webpack.config.js` - Encore configuration + +## Key Workflows + +### Adding a New Module + +1. Create entity with `ModuleAwareInterface` +2. Add to Module entity fixtures/database +3. Create voter (or rely on ModuleVoter) +4. Configure API Platform security +5. Add Vue.js view component +6. Add route to router.js +7. Add menu item to AppMenu.vue + +### Adding a New Permission-Controlled Feature + +1. Determine permission level (module vs. entity) +2. If entity-level: create/update Voter +3. Add security checks in controller/API +4. Update frontend to check permissions before showing UI +5. Test with different user roles + +### Database Schema Changes + +1. Modify entity attributes +2. `php bin/console make:migration` +3. Review generated migration +4. `php bin/console doctrine:migrations:migrate` +5. `php bin/console doctrine:schema:validate` to verify +6. Update fixtures if needed + +## Common Patterns + +**Check Module Permission (Backend):** +```php +if (!$user->hasModulePermission('contacts', 'view')) { + throw new AccessDeniedException(); +} +``` + +**Check Entity Permission (Backend):** +```php +$this->denyAccessUnlessGranted('EDIT', $contact); +``` + +**API Platform Security:** +```php +#[ApiResource( + security: "is_granted('ROLE_USER')", + operations: [ + new Get(security: "is_granted('VIEW', object)") + ] +)] +``` + +**Project Access Check:** +```php +if (!$project->hasAccess($user)) { + throw new AccessDeniedException(); +} +``` + +**Frontend Permission Check:** +```javascript +import { usePermissions } from '@/composables/usePermissions' + +const { hasPermission } = usePermissions() + +if (hasPermission('contacts', 'create')) { + // Show create button +} +``` + +## Additional Documentation + +- `docs/LOGIN.md` - Authentication system details +- `docs/PERMISSIONS.md` - Modular permission system +- `docs/USER-CRUD.md` - User management with API Platform +- `docs/PROJECT_TASKS_MODULE.md` - Project tasks implementation +- `.github/copilot-instructions.md` - Detailed development guidelines diff --git a/SECURITY_ARCHITECTURE.md b/SECURITY_ARCHITECTURE.md new file mode 100644 index 0000000..8bccc22 --- /dev/null +++ b/SECURITY_ARCHITECTURE.md @@ -0,0 +1,620 @@ +# Security Architecture - myCRM + +## Overview + +myCRM implements a **multi-layered security architecture** combining Symfony's authentication/authorization system with custom role-based access control (RBAC), project-based access control, and API Platform integration. The system uses: + +1. **Symfony Security System** - Authentication, password hashing, user providers +2. **Custom Voters** - Fine-grained authorization for entities and modules +3. **API Platform Security** - Per-operation security expressions +4. **Event Listeners** - Security checks during entity lifecycle +5. **Query Extensions** - Automatic query filtering for accessible data + +--- + +## Entity Relationship Model + +### Core Security Entities + +``` +User (users table) +├── id, email, firstName, lastName, password, isActive +├── roles: list (Symfony standard roles like ROLE_ADMIN, ROLE_USER) +├── userRoles: Collection (custom application roles - ManyToMany) +├── passwordSetupToken, passwordSetupTokenExpiresAt +├── createdAt, lastLoginAt +└── Methods: + └── hasModulePermission(moduleCode, action): bool + +Role (roles table) +├── id, name, description, isSystem (cannot be deleted if true) +├── users: Collection (ManyToMany inverse side) +├── permissions: Collection (OneToMany) +├── createdAt, updatedAt +└── Constraint: SystemRoleProtection validator prevents system role modification + +Module (modules table) +├── id, name, code (unique), description, icon, sortOrder, isActive +├── permissions: Collection (OneToMany) +└── Purpose: Defines application modules (contacts, projects, project_tasks, etc.) + +RolePermission (role_permissions table) +├── id, role_id, module_id (unique constraint: role_id + module_id) +├── canView, canCreate, canEdit, canDelete, canExport, canManage: bool +└── Unique Constraint: Ensures one permission per role-module pair + +ModuleAwareInterface (interface) +└── getModuleName(): string (implemented by Contact, Project, ProjectTask) +``` + +### Project Management Entities + +``` +Project (projects table) +├── id, name, description, projectNumber, orderNumber +├── customer: Contact (ManyToOne) +├── status: ProjectStatus (ManyToOne) +├── owner: User (ManyToOne - REQUIRED) +├── teamMembers: Collection (ManyToMany) +├── gitRepositories: Collection (OneToMany) +├── startDate, endDate, orderDate, budget, hourContingent +├── isPrivate: bool +├── createdAt, updatedAt +├── Implements: ModuleAwareInterface (returns 'projects') +└── Methods: + ├── hasAccess(user): bool (owner or team member) + ├── isTeamMember(user): bool + ├── addTeamMember(user), removeTeamMember(user) + └── addGitRepository(repo), removeGitRepository(repo) + +ProjectTask (project_tasks table) +├── id, name, description +├── project: Project (ManyToOne, nullable) +├── budget, hourContingent, hourlyRate, totalPrice +├── createdAt, updatedAt +├── Implements: ModuleAwareInterface (returns 'project_tasks') +└── Security: Can be project-related OR project-independent (admin-only) + +GitRepository (git_repositories table) +├── id, url, localPath, branch, provider, accessToken +├── project: Project (ManyToOne - REQUIRED) +├── name, description +├── lastSync, createdAt, updatedAt +└── Security: Access controlled via associated project + +Contact (contacts table) +├── id, companyName, companyNumber, street, zipCode, city, country +├── phone, fax, email, website, taxNumber, vatNumber +├── isDebtor, isCreditor, isActive +├── contactPersons: Collection (OneToMany) +├── notes, createdAt, updatedAt +├── Implements: ModuleAwareInterface (returns 'contacts') + +ContactPerson (contact_persons table) +├── id, contact_id (ManyToOne, no separate permissions) +├── salutation, title, firstName, lastName, position, department +├── phone, mobile, email, isPrimary +└── Security: Inherits from parent Contact +``` + +--- + +## Security Flow Layers + +### Layer 1: Authentication +**File**: `config/packages/security.yaml`, `src/Security/LoginFormAuthenticator.php`, `src/Security/PocketIdAuthenticator.php` + +- **Provider**: User loaded by email from database +- **Authenticators**: + - LoginFormAuthenticator (form-based login) + - PocketIdAuthenticator (OAuth/PocketId integration) +- **Password Hashing**: Symfony's PasswordHasher (bcrypt) +- **Session**: Stateless=false (sessions enabled for web UI) +- **Remember Me**: 1-week cookie-based persistence + +### Layer 2: Base Authorization (Standard Roles) + +User has two types of roles: + +#### A. Symfony Standard Roles (stored in `roles` JSON column) +- `ROLE_USER` - Default role for authenticated users +- `ROLE_ADMIN` - Full system access (all voters return true) +- Used in access_control patterns and voter logic + +#### B. Application Roles (stored via user_roles ManyToMany) +- Custom business roles (e.g., "Vertriebsmitarbeiter", "Betrachter") +- Linked to modules via RolePermission +- Define granular per-module actions + +### Layer 3: Voter-Based Authorization + +**File**: `src/Security/Voter/` + +#### ModuleVoter +- **Purpose**: Controls module-level access (contacts, projects, etc.) +- **Attributes**: `VIEW`, `CREATE`, `EDIT`, `DELETE`, `EXPORT`, `MANAGE` +- **Subject Types**: + - String (module code) for collection operations: `is_granted('VIEW', 'contacts')` + - Object implementing ModuleAwareInterface for item operations: `is_granted('VIEW', $contact)` +- **Logic**: + ``` + 1. If user not logged in → ABSTAIN + 2. If user inactive → DENY + 3. If ROLE_ADMIN → ALLOW (all attributes) + 4. Otherwise → Check user.hasModulePermission(moduleCode, action) + - Iterates through user roles → role permissions + - Finds permission for module + - Checks corresponding canView/canCreate/canEdit/canDelete/canExport/canManage flag + ``` + +#### ProjectVoter +- **Purpose**: Controls project-level access +- **Attributes**: `VIEW`, `EDIT`, `DELETE`, `CREATE` +- **Access Rules**: + - `CREATE`: Any authenticated user can create projects + - `VIEW`: Owner or team member + - `EDIT`: Owner or team member + - `DELETE`: Owner only +- **Used in**: Project API operations + +#### ProjectTaskVoter +- **Purpose**: Controls project task access +- **Attributes**: `VIEW`, `EDIT`, `DELETE`, `CREATE` +- **Access Rules**: + - For tasks WITH project: Same as ProjectVoter + - For tasks WITHOUT project: ROLE_ADMIN only + - Admin bypass: ROLE_ADMIN can perform all actions +- **Dependency**: References associated project's access rules + +#### GitRepositoryVoter +- **Purpose**: Controls git repository access +- **Attributes**: `VIEW`, `EDIT`, `DELETE` +- **Access Rules**: Tied to associated project + - `VIEW/EDIT`: Owner or team member of project + - `DELETE`: Project owner only + - No project → DENY all + +### Layer 4: API Platform Security Expressions + +**File**: Entity classes with `#[ApiResource]` attributes + +API operations define inline security expressions: + +```php +#[ApiResource( + operations: [ + new GetCollection(security: "is_granted('VIEW', 'contacts')"), + new Get(security: "is_granted('VIEW', object)"), + new Post(security: "is_granted('CREATE', 'contacts')"), + new Put(security: "is_granted('EDIT', object)"), + new Delete(security: "is_granted('DELETE', object)") + ] +)] +``` + +**Evaluation**: +1. API request arrives +2. Security expression evaluated using SecurityExpressionLanguage +3. If expression returns false → 403 Forbidden +4. If true → Request processed (entity-level validators still run) + +### Layer 5: Event Listeners (Doctrine) + +**File**: `src/EventListener/ProjectTaskSecurityListener.php` + +Attribute: `#[AsEntityListener(event: Events::prePersist, entity: ProjectTask::class)]` + +**Purpose**: Additional security check before persisting ProjectTask + +**Logic**: +```php +if (task.project === null) { + // Project-independent task + require ROLE_ADMIN +} else { + // Project-related task + allow if user is ROLE_ADMIN or user.hasAccess(project) +} +``` + +**When it runs**: Before `flush()` during POST/PUT operations + +### Layer 6: Query Extensions (Collection Filtering) + +**File**: `src/Doctrine/Extension/ProjectAccessExtension.php` + +Implements: `QueryCollectionExtensionInterface` + +**Purpose**: Automatically filter Project collection queries to accessible projects + +**Logic**: +```sql +WHERE project.owner = :current_user + OR teamMembers = :current_user +``` + +**When it runs**: Before any Project GetCollection operation + +**Alternative Approach**: +`src/State/ProjectCollectionProvider.php` provides project collection via `findUserProjects(user)` + +--- + +## Security Implementation Patterns + +### Pattern 1: Module-Based Permissions + +For entities implementing `ModuleAwareInterface` that track module-level permissions: + +**File**: Contact, Project, ProjectTask entities + +1. Entity implements `ModuleAwareInterface::getModuleName()` → returns module code +2. API security expression: `is_granted('VIEW', 'contacts')` +3. ModuleVoter checks user.hasModulePermission('contacts', 'view') +4. User.hasModulePermission() iterates through roles → permissions + +**Advantage**: Centralized permission management in Role/RolePermission tables + +### Pattern 2: Object-Level Ownership Permissions + +For entities with specific owner relationships: + +**File**: Project, ProjectTask, GitRepository + +1. Entity has owner relationship: `User $owner` +2. API security expression: `is_granted('DELETE', object)` +3. Voter checks: `object.getOwner() === user` + +**Advantage**: Granular control based on entity relationships + +### Pattern 3: Project-Based Access Control + +For entities related to projects: + +**File**: ProjectTask, GitRepository + +1. Access tied to parent project +2. Check: `project.hasAccess(user)` (owner or team member) +3. Combined with module voter for additional layer + +**Advantage**: Role-based team access to projects and related data + +### Pattern 4: Admin Bypass + +Implemented consistently: + +```php +if (in_array('ROLE_ADMIN', $user->getRoles())) { + return true; // All checks pass +} +``` + +**File**: ModuleVoter, ProjectTaskVoter + +--- + +## API Platform Configuration + +**File**: `config/packages/api_platform.yaml` + +```yaml +api_platform: + title: myCRM API + version: 1.0.0 + defaults: + stateless: false + cache_headers: + vary: ['Content-Type', 'Authorization', 'Origin'] + formats: + jsonld: ['application/ld+json'] + json: ['application/json'] + html: ['text/html'] +``` + +### Per-Entity API Resource Configuration + +**Example**: Project Entity + +```php +#[ApiResource( + operations: [ + new GetCollection( + security: "is_granted('VIEW', 'projects')", + stateless: false + ), + new Get( + security: "is_granted('VIEW', object)", + stateless: false + ), + new Post( + security: "is_granted('CREATE', 'projects')", + stateless: false + ), + new Put( + security: "is_granted('EDIT', object)", + stateless: false + ), + new Delete( + security: "is_granted('DELETE', object)", + stateless: false + ) + ], + normalizationContext: ['groups' => ['project:read']], + denormalizationContext: ['groups' => ['project:write']], + order: ['startDate' => 'DESC'] +)] +#[ApiFilter(BooleanFilter::class, properties: ['isPrivate'])] +#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial', ...])] +#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])] +class Project implements ModuleAwareInterface { ... } +``` + +### Serializer Groups + +**Purpose**: Control what fields are returned in API responses based on operation + +- `user:read` - Fields returned when reading users +- `user:write` - Fields accepted when creating/updating users +- `project:read` - Fields returned in project responses +- `project:write` - Fields accepted in project POST/PUT +- `role:read` - Role entity read fields +- `role_permission:read` - Permission entity read fields + +**File**: Entity attributes `#[Groups(['group:read', 'group:write'])]` + +--- + +## Security Checklist During Development + +### Creating New Module-Based Entities + +1. Implement `ModuleAwareInterface` + ```php + public function getModuleName(): string { return 'module_code'; } + ``` + +2. Add Module record to database + ```php + $module = new Module(); + $module->setName('Module Name'); + $module->setCode('module_code'); + ``` + +3. Create API Resource with security + ```php + #[ApiResource( + operations: [ + new GetCollection(security: "is_granted('VIEW', 'module_code')"), + new Get(security: "is_granted('VIEW', object)"), + new Post(security: "is_granted('CREATE', 'module_code')"), + new Put(security: "is_granted('EDIT', object)"), + new Delete(security: "is_granted('DELETE', object)") + ] + )] + ``` + +4. Define RolePermissions for existing roles + +### Adding Project-Based Access to New Entity + +1. Add `project` relationship + ```php + #[ORM\ManyToOne(targetEntity: Project::class)] + private ?Project $project = null; + ``` + +2. Create custom Voter (similar to ProjectTaskVoter) + ```php + private function canView(Entity $entity, User $user): bool { + if (null === $entity->getProject()) { + return in_array('ROLE_ADMIN', $user->getRoles()); + } + return $entity->getProject()->hasAccess($user); + } + ``` + +3. Add security expressions to API Resource + ```php + new Get(security: "is_granted('VIEW', object)") + ``` + +### Debugging Permission Issues + +1. **Check user roles**: + ```php + var_dump($this->getUser()->getRoles()); // Symfony standard roles + var_dump($this->getUser()->getUserRoles()); // Application roles + ``` + +2. **Check module permissions**: + ```php + var_dump($user->hasModulePermission('contacts', 'view')); + ``` + +3. **Check voter decision**: + ```php + var_dump($this->isGranted('VIEW', 'contacts')); + var_dump($this->isGranted('VIEW', $contact)); + ``` + +4. **Enable security debug** in dev toolbar + +--- + +## Voter Decision Flow Diagram + +``` +Request arrives for [Attribute, Subject] + ↓ +Symfony checks all registered voters in order + ↓ +Each voter.supports(attribute, subject)? + ├─ ModuleVoter + │ ├─ Attribute in [VIEW, CREATE, EDIT, DELETE, EXPORT, MANAGE]? + │ └─ Subject is string OR implements ModuleAwareInterface? + │ + ├─ ProjectVoter + │ ├─ Attribute in [VIEW, EDIT, DELETE, CREATE]? + │ └─ Subject is 'projects' or Project instance? + │ + ├─ ProjectTaskVoter + │ ├─ Attribute in [VIEW, EDIT, DELETE, CREATE]? + │ └─ Subject is 'project_tasks' or ProjectTask instance? + │ + ├─ GitRepositoryVoter + │ ├─ Attribute in [VIEW, EDIT, DELETE]? + │ └─ Subject is GitRepository instance? + │ + └─ [Other voters...] + + ↓ + +For each voter that supports: + voter.voteOnAttribute(attribute, subject, token) + + Returns: + ALLOW (vote: YES) → Stop here, GRANT access + DENY (vote: NO) → Stop here, DENY access + ABSTAIN (vote: null) → Continue to next voter + + ↓ + +Default if all ABSTAIN: DENY (secure by default) +``` + +--- + +## Files Organization + +### Security Components +``` +src/Security/ +├── Voter/ +│ ├── ModuleVoter.php (Module-level access) +│ ├── ProjectVoter.php (Project ownership/team) +│ ├── ProjectTaskVoter.php (Project-based + admin) +│ └── GitRepositoryVoter.php (Project-based) +├── LoginFormAuthenticator.php +└── PocketIdAuthenticator.php +``` + +### Entities +``` +src/Entity/ +├── User.php (Authentication + module permissions) +├── Role.php (Application role definition) +├── RolePermission.php (Role-module permission mapping) +├── Module.php (Application module registry) +├── Interface/ +│ └── ModuleAwareInterface.php +├── Project.php (Owner + team members) +├── ProjectTask.php (Optional project association) +├── GitRepository.php (Project association) +├── Contact.php (Module-aware) +├── ContactPerson.php (No separate permissions) +└── ProjectStatus.php +``` + +### Event Listeners +``` +src/EventListener/ +├── ProjectTaskSecurityListener.php (prePersist validation) +├── LoginSuccessListener.php +├── GitRepositoryCacheListener.php +└── [Others...] +``` + +### Query Extensions +``` +src/Doctrine/Extension/ +└── ProjectAccessExtension.php (Auto-filter GetCollection) +``` + +### API Filters +``` +src/Filter/ +└── ProjectTaskProjectFilter.php (hasProject filter) +``` + +### Configuration +``` +config/ +├── packages/ +│ ├── security.yaml (Firewall, authentication, access_control) +│ ├── api_platform.yaml (API Platform defaults) +│ └── [Others...] +└── services.yaml (Service wiring) +``` + +--- + +## Default Access Control Rules + +From `config/packages/security.yaml`: + +```yaml +access_control: + - { path: ^/login, roles: PUBLIC_ACCESS } + - { path: ^/password-reset, roles: PUBLIC_ACCESS } + - { path: ^/password-setup, roles: PUBLIC_ACCESS } + - { path: ^/connect/pocketid, roles: PUBLIC_ACCESS } + - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } + - { path: ^/, roles: ROLE_USER } +``` + +- `/api/*` requires full authentication (not remember_me) +- Other app routes require `ROLE_USER` or `ROLE_ADMIN` +- Public routes for auth bypass + +--- + +## Testing Security + +### Unit Testing Voters + +```php +public function testProjectVoterDenyNonOwner(): void { + $user = new User(); + $owner = new User(); + $project = new Project(); + $project->setOwner($owner); + + $token = $this->createToken($user); + $voter = new ProjectVoter(); + + $this->assertEquals( + Voter::DENY, + $voter->vote($token, $project, [ProjectVoter::DELETE]) + ); +} +``` + +### API Integration Testing + +```php +public function testDeleteProjectForbiddenForNonOwner(): void { + $this->client->request('DELETE', '/api/projects/1'); + $this->assertResponseStatusCodeSame(403); +} +``` + +--- + +## Common Issues & Solutions + +| Issue | Cause | Solution | +|-------|-------|----------| +| 403 on API call | Missing module permission | Check RolePermission for user's roles | +| Admin can't access entity | Voter logic error | Verify ROLE_ADMIN bypass in voter | +| Project team member denied | hasAccess() returns false | Check project.owner or project.teamMembers | +| Query returns all items | ProjectAccessExtension not applied | Verify entity is Project class | +| Permission check always fails | Wrong attribute/subject format | Use `is_granted('VIEW', 'module_code')` for strings, `is_granted('VIEW', $object)` for objects | + +--- + +## References + +- Symfony Security: https://symfony.com/doc/current/security.html +- API Platform Security: https://api-platform.com/docs/core/security/ +- Voters: https://symfony.com/doc/current/security/voters.html +- Expression Language: https://symfony.com/doc/current/components/expression_language/ diff --git a/assets/js/components/CrudDataTable.vue b/assets/js/components/CrudDataTable.vue index debc5b5..7ab29ec 100644 --- a/assets/js/components/CrudDataTable.vue +++ b/assets/js/components/CrudDataTable.vue @@ -93,6 +93,7 @@ :showFilterOperator="column.showFilterOperator !== false" :frozen="freezeFirstColumn && index === 0" :alignFrozen="freezeFirstColumn && index === 0 ? 'left' : undefined" + :filterField="column.filterField || column.field || column.key" >