- 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.
621 lines
19 KiB
Markdown
621 lines
19 KiB
Markdown
# 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<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 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/
|