myCRM/SECURITY_ARCHITECTURE.md
olli 8a132d2fb9 feat: Implement ProjectTask module with full CRUD functionality
- 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.
2025-11-14 17:12:40 +01:00

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/