- Added ProjectTask entity with fields for name, description, budget, hour contingent, hourly rate, and total price. - Created ProjectTaskRepository with methods for querying tasks by project and user access. - Implemented ProjectTaskVoter for fine-grained access control based on user roles and project membership. - Developed ProjectTaskSecurityListener to enforce permission checks during task creation. - Introduced custom ProjectTaskProjectFilter for filtering tasks based on project existence. - Integrated ProjectTask management in the frontend with Vue.js components, including CRUD operations and filtering capabilities. - Added API endpoints for ProjectTask with appropriate security measures. - Created migration for project_tasks table in the database. - Updated documentation to reflect new module features and usage.
19 KiB
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:
- Symfony Security System - Authentication, password hashing, user providers
- Custom Voters - Fine-grained authorization for entities and modules
- API Platform Security - Per-operation security expressions
- Event Listeners - Security checks during entity lifecycle
- 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<string> (Symfony standard roles like ROLE_ADMIN, ROLE_USER)
├── userRoles: Collection<Role> (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<User> (ManyToMany inverse side)
├── permissions: Collection<RolePermission> (OneToMany)
├── createdAt, updatedAt
└── Constraint: SystemRoleProtection validator prevents system role modification
Module (modules table)
├── id, name, code (unique), description, icon, sortOrder, isActive
├── permissions: Collection<RolePermission> (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<User> (ManyToMany)
├── gitRepositories: Collection<GitRepository> (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<ContactPerson> (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 usersROLE_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)
- String (module code) for collection operations:
- 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 projectsVIEW: Owner or team memberEDIT: Owner or team memberDELETE: 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 projectDELETE: 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:
#[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:
- API request arrives
- Security expression evaluated using SecurityExpressionLanguage
- If expression returns false → 403 Forbidden
- 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:
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:
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
- Entity implements
ModuleAwareInterface::getModuleName()→ returns module code - API security expression:
is_granted('VIEW', 'contacts') - ModuleVoter checks user.hasModulePermission('contacts', 'view')
- 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
- Entity has owner relationship:
User $owner - API security expression:
is_granted('DELETE', object) - 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
- Access tied to parent project
- Check:
project.hasAccess(user)(owner or team member) - Combined with module voter for additional layer
Advantage: Role-based team access to projects and related data
Pattern 4: Admin Bypass
Implemented consistently:
if (in_array('ROLE_ADMIN', $user->getRoles())) {
return true; // All checks pass
}
File: ModuleVoter, ProjectTaskVoter
API Platform Configuration
File: config/packages/api_platform.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
#[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 usersuser:write- Fields accepted when creating/updating usersproject:read- Fields returned in project responsesproject:write- Fields accepted in project POST/PUTrole:read- Role entity read fieldsrole_permission:read- Permission entity read fields
File: Entity attributes #[Groups(['group:read', 'group:write'])]
Security Checklist During Development
Creating New Module-Based Entities
-
Implement
ModuleAwareInterfacepublic function getModuleName(): string { return 'module_code'; } -
Add Module record to database
$module = new Module(); $module->setName('Module Name'); $module->setCode('module_code'); -
Create API Resource with security
#[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)") ] )] -
Define RolePermissions for existing roles
Adding Project-Based Access to New Entity
-
Add
projectrelationship#[ORM\ManyToOne(targetEntity: Project::class)] private ?Project $project = null; -
Create custom Voter (similar to ProjectTaskVoter)
private function canView(Entity $entity, User $user): bool { if (null === $entity->getProject()) { return in_array('ROLE_ADMIN', $user->getRoles()); } return $entity->getProject()->hasAccess($user); } -
Add security expressions to API Resource
new Get(security: "is_granted('VIEW', object)")
Debugging Permission Issues
-
Check user roles:
var_dump($this->getUser()->getRoles()); // Symfony standard roles var_dump($this->getUser()->getUserRoles()); // Application roles -
Check module permissions:
var_dump($user->hasModulePermission('contacts', 'view')); -
Check voter decision:
var_dump($this->isGranted('VIEW', 'contacts')); var_dump($this->isGranted('VIEW', $contact)); -
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:
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_USERorROLE_ADMIN - Public routes for auth bypass
Testing Security
Unit Testing Voters
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
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/