# 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/